Compare commits
@@ -0,0 +1,39 @@
|
||||
# Ticket CLI
|
||||
|
||||
**Use `tooling/db/ticket`** for all ticket operations. Never use `sqlite3` directly (crashes in Claude Code).
|
||||
|
||||
## Positional arguments — not flags
|
||||
|
||||
`ticket create` uses **positional** arguments for `type` and `title`. There is no `--title` flag.
|
||||
|
||||
```bash
|
||||
# CORRECT — type and title are positional
|
||||
tooling/db/ticket create story "My ticket title" --description "Details here" --team server --priority low
|
||||
|
||||
# WRONG — --title does not exist, gets absorbed into the title string
|
||||
tooling/db/ticket create story --title "My ticket title" --description "Details here"
|
||||
# Creates a ticket titled: "--title My ticket title"
|
||||
```
|
||||
|
||||
## Full usage
|
||||
|
||||
```bash
|
||||
# Create
|
||||
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
|
||||
# type: initiative | epic | story | task | bug
|
||||
# priority: critical | high | medium | low
|
||||
|
||||
# Read
|
||||
tooling/db/ticket show <id>
|
||||
tooling/db/ticket list [--sprint N] [--team T] [--status S]
|
||||
|
||||
# Update
|
||||
tooling/db/ticket assign <id> <agent>
|
||||
```
|
||||
|
||||
## Key rules
|
||||
|
||||
- **Type and title are positional** — everything else is a flag
|
||||
- **Quote the title** — always wrap in double quotes to handle spaces
|
||||
- **Never use `sqlite3` CLI** — it crashes (std::bad_alloc). Use `tooling/db/sqlite-query` or `tooling/db/sqlite-exec` for raw SQL
|
||||
- **Verify after create** — run `tooling/db/ticket show <id>` to confirm the title is clean
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"autoMemoryDirectory": "/home/jeroenschweitzer/Projects/settled-reach/.memory",
|
||||
"env": {
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
|
||||
"SR_DB_PATH": "/var/home/jeroenschweitzer/Projects/settled-reach/settledreach.db"
|
||||
},
|
||||
"teammateMode": "in-process",
|
||||
"permissions": {
|
||||
@@ -63,9 +64,10 @@
|
||||
"Bash(list *)",
|
||||
"Bash(tree *)",
|
||||
"Bash(sed -n *)",
|
||||
"Bash(.claude/skills/sprint-start/scripts/start-sprint.sh *)",
|
||||
"Bash(.claude/skills/sprint-start/scripts/sprint-teardown.sh *)",
|
||||
|
||||
"Skill(git-commit)",
|
||||
"Skill(worktree-update)",
|
||||
"Skill(sprint-start)",
|
||||
"Skill(sprint-plan)",
|
||||
"Skill(pr-push)",
|
||||
|
||||
@@ -34,7 +34,35 @@ git branch --show-current
|
||||
|
||||
If on `main`, stop: "You're on main. Switch to a team branch first."
|
||||
|
||||
### 1b. Runtime smoke test (MANDATORY)
|
||||
### 1b. Zero warnings policy (MANDATORY)
|
||||
|
||||
Before pushing, verify the branch has **zero lint warnings**. Any warning
|
||||
must be either fixed or suppressed with a commented justification.
|
||||
|
||||
**For client/visual branches:**
|
||||
```bash
|
||||
gdlint client/scripts/ client/ui/ 2>&1
|
||||
```
|
||||
|
||||
If warnings remain, fix them before pushing. For warnings that cannot be
|
||||
fixed (e.g. intentional long lines in data literals), add a `# gdlint:
|
||||
ignore` comment with a reason.
|
||||
|
||||
**For server branches:**
|
||||
```bash
|
||||
cargo clippy -- -D warnings 2>&1
|
||||
```
|
||||
|
||||
**For CI/tooling branches:**
|
||||
```bash
|
||||
ruff check tooling/ 2>&1
|
||||
```
|
||||
|
||||
The goal is zero warnings in the pre-push output. Advisory warnings that
|
||||
the pre-push hook reports as "(advisory, not blocking)" should still be
|
||||
zero — they are advisory only because we haven't enforced them yet.
|
||||
|
||||
### 1c. Runtime smoke test (MANDATORY)
|
||||
|
||||
Before pushing, verify the game actually runs. This is non-negotiable —
|
||||
Sprint 28 proved that code review without runtime testing misses critical
|
||||
@@ -66,8 +94,22 @@ works on screen before invoking `/pr-push`. If they haven't, ask:
|
||||
|
||||
```bash
|
||||
git status
|
||||
git diff --stat
|
||||
```
|
||||
|
||||
**Run both commands from the repo root** (`git rev-parse --show-toplevel`).
|
||||
Running from a subdirectory can cause paths to not resolve, hiding real
|
||||
changes — Sprint 30 proved this when `git diff HEAD -- server/src/bin/atlas.rs`
|
||||
returned 0 lines from the wrong CWD, masking uncommitted agent work.
|
||||
|
||||
**CRITICAL: Do not trust "already done" claims without checking git state.**
|
||||
If agents report that work was "already implemented in a prior commit," verify
|
||||
by checking `git status` and `git diff --stat` first. Grepping source files
|
||||
only proves the code exists on disk — it does NOT prove the code is committed.
|
||||
Uncommitted working-tree changes look identical to committed code when you
|
||||
read files. Only `git status` distinguishes "already shipped" from "just
|
||||
written by a teammate."
|
||||
|
||||
If there are uncommitted changes (staged or unstaged), run the **commit skill**
|
||||
first. Use the `/git-commit` skill to group changes into logical commits with
|
||||
proper conventional commit messages. Wait for commit to complete before
|
||||
|
||||
@@ -16,16 +16,15 @@ on the branch type. All reviewers must approve for a clean review.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree
|
||||
### 0. Branch guard — MUST be run from the `main` branch
|
||||
|
||||
```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.
|
||||
"PR reviews must be run from the `main` branch."
|
||||
Do NOT proceed with the review.
|
||||
Stop and wait for the user to invoke `/pr-review` from main.
|
||||
|
||||
### 0b. Verify runtime smoke test was performed
|
||||
@@ -48,6 +47,19 @@ godot --headless --path client --quit 2>&1 | grep -i "SCRIPT ERROR"
|
||||
If script errors appear in the branch diff files, flag them immediately
|
||||
before spawning reviewers — no point reviewing code that doesn't parse.
|
||||
|
||||
### 0c. Zero warnings check
|
||||
|
||||
The project enforces a **zero warnings policy**. Before spawning reviewers,
|
||||
check if the branch introduces lint warnings:
|
||||
|
||||
- **client/visual:** `gdlint client/scripts/ client/ui/` should report 0 issues
|
||||
- **server:** `cargo clippy -- -D warnings` should be clean
|
||||
- **ci/tooling:** `ruff check tooling/` should be clean
|
||||
|
||||
If warnings exist, note the count in the review output. Reviewers should
|
||||
flag any **new** warnings introduced by the branch as `warning` severity.
|
||||
Pre-existing warnings are not PR blockers but should be tracked for cleanup.
|
||||
|
||||
### 1. Determine the branch to review
|
||||
|
||||
If the user provided a branch name as argument, use it. Otherwise list open
|
||||
@@ -99,39 +111,24 @@ Three-dot diff with pathspec exclusions is unreliable. Instead, either:
|
||||
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||
raw diff to reviewers — cleaner context, better reviews.
|
||||
|
||||
**IMPORTANT — use team directory paths for ALL agents.** Each team branch
|
||||
is checked out in its own directory at:
|
||||
**Reviewer agents read source files via `git show`.** Sprint branches
|
||||
use the naming pattern `sprint-{N}/{team}`. To read a file from the
|
||||
branch being reviewed:
|
||||
|
||||
```
|
||||
/var/mnt/data/projects/settled-reach/<branch>/
|
||||
```bash
|
||||
git show origin/<branch>:<path>
|
||||
```
|
||||
|
||||
For example, the `copy` team directory is at:
|
||||
```
|
||||
/var/mnt/data/projects/settled-reach/copy/content/dialogue/...
|
||||
For example:
|
||||
```bash
|
||||
git show origin/sprint-31/server:server/src/bin/atlas.rs
|
||||
```
|
||||
|
||||
**All reviewer agents** (regardless of Bash access) should read source files
|
||||
from the team directory using the Read tool. This is more reliable than
|
||||
`git show origin/<branch>:<path>` because:
|
||||
- All agents have Read access (no Bash dependency)
|
||||
- Files are always the actual branch checkout (no stale cache)
|
||||
- No risk of accidentally reading from main's working directory
|
||||
If the sprint branch has an active worktree (under `.sprint/`), reviewers
|
||||
can also use the Read tool with the worktree path. But `git show` is
|
||||
the reliable default — it works whether or not a worktree exists.
|
||||
|
||||
When constructing reviewer prompts, tell agents to read files from the
|
||||
team directory. Example instruction for agents:
|
||||
|
||||
```
|
||||
Read the changed files from the team directory. The branch is checked
|
||||
out at: /var/mnt/data/projects/settled-reach/<branch>/
|
||||
|
||||
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
|
||||
use: /var/mnt/data/projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
|
||||
```
|
||||
|
||||
Also tell agents to read relevant `decisions/*.md` files from the same
|
||||
directory (they're identical to main, but using the team directory path
|
||||
keeps agents grounded in the correct location).
|
||||
Also tell agents to read relevant `decisions/*.md` files for context.
|
||||
|
||||
### 4. Spawn reviewers in parallel
|
||||
|
||||
|
||||
@@ -2,17 +2,12 @@
|
||||
|
||||
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||
|
||||
**All reviewer agents read from team directories.** Each team branch is
|
||||
checked out in its own directory at:
|
||||
`/var/mnt/data/projects/settled-reach/<branch>/`
|
||||
|
||||
Tell every reviewer agent to read source files from the team directory
|
||||
using the Read tool. Include the directory path and a list of changed
|
||||
files in every prompt. Do NOT rely on `git show` or paste file contents —
|
||||
agents can read directly from the directory.
|
||||
|
||||
Note: cross-directory reading is only permitted for review agents spawned
|
||||
from the `main` team. Team agents must stay within their own directory.
|
||||
**Reviewer agents read source files via `git show` or from sprint
|
||||
worktrees.** Sprint branches use `sprint-{N}/{team}` naming. Include
|
||||
the branch name and a list of changed files in every prompt. The
|
||||
default approach is `git show origin/<branch>:<path>`. If an active
|
||||
worktree exists under `.sprint/`, agents can also use the Read tool
|
||||
with the worktree path.
|
||||
|
||||
## Code reviews (`server`, `client`, `ci`)
|
||||
|
||||
|
||||
@@ -154,12 +154,9 @@ Create `docs/sprints/sprint-N/` and write one file per team.
|
||||
Read the template at `references/briefing-template.md` in this skill directory
|
||||
for the exact file structure.
|
||||
|
||||
**IMPORTANT — relative paths only:** Each team works in its own directory
|
||||
containing the full repo (`server/`, `client/`, `docs/`, etc.). All file
|
||||
paths in briefings must be relative to the working directory. Example:
|
||||
`server/src/bridge/types.rs`, not `/absolute/path/to/server/src/...` or
|
||||
paths that navigate outside (`../sibling-dir/...`).
|
||||
Agents must stay within their team's working directory.
|
||||
**Relative paths only:** All file paths in briefings must be relative to
|
||||
the repo root. Example: `server/src/bridge/types.rs`, not absolute paths.
|
||||
Each sprint branch (`sprint-{N}/{team}`) contains the full repo.
|
||||
|
||||
Key requirements per file:
|
||||
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
|
||||
|
||||
@@ -9,7 +9,7 @@ Each team gets one briefing file at `docs/sprints/sprint-N/<team>.md`.
|
||||
|
||||
**Goal:** <One-sentence sprint goal, shared across all teams>
|
||||
|
||||
**Branch:** `<team>`
|
||||
**Branch:** `sprint-{N}/<team>`
|
||||
**Agents:** <Agent names and roles>
|
||||
|
||||
## Carry-over from Sprint N-1
|
||||
@@ -57,7 +57,7 @@ One bullet per ticket with:
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
\```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(<scope>): description" --description "body" --base main --head <branch>
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(<scope>): description" --description "body" --base main --head sprint-{N}/<team>
|
||||
\```
|
||||
```
|
||||
|
||||
@@ -73,7 +73,8 @@ The `joint.md` file additionally includes:
|
||||
|
||||
| Team | Branch | Agents | Scope |
|
||||
|------|--------|--------|-------|
|
||||
| server | `server` | Dudley (simulation), Oscar (networking) | Rust/bevy_ecs simulation |
|
||||
| client | `client` | Stig (UI), Oscar (networking) | Godot client rendering |
|
||||
| server | `sprint-{N}/server` | Dudley (simulation), Oscar (networking) | server/, Rust/bevy_ecs simulation |
|
||||
| client | `sprint-{N}/client` | Stig (UI), Oscar (networking) | client/, Godot rendering |
|
||||
| copy | `sprint-{N}/copy` | Mellanie, Paula, Miri | wiki/, docs/atlas/, content/ |
|
||||
| joint | both | All implementation agents | Integration, proofs, cross-team schema |
|
||||
| content | (none) | Mellanie, Paula, Miri, Araminta | Content authoring, no code branch |
|
||||
|
||||
@@ -25,8 +25,8 @@ The current branch IS the team. Read it with:
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
Valid team branches: `server`, `client`, `copy`, `audio`, `visual`, `ci`,
|
||||
`planning`.
|
||||
Sprint branches follow the pattern `sprint-{N}/{team}` (e.g. `sprint-31/server`).
|
||||
Valid team names: `server`, `client`, `copy`, `audio`, `visual`, `ci`, `planning`.
|
||||
|
||||
If on `main`, follow the **Main branch workflow** below instead of
|
||||
the team branch workflow (steps 2–8).
|
||||
@@ -130,6 +130,18 @@ If the user raises items that should be tracked, create Q-NNN entries
|
||||
or backlog tickets on the spot. If process changes are agreed, update
|
||||
the relevant skill files or CLAUDE.md immediately — don't defer them.
|
||||
|
||||
#### A1c. Clean up sprint worktrees
|
||||
|
||||
Remove ephemeral worktrees for the closed sprint. Run the teardown script:
|
||||
|
||||
```bash
|
||||
.claude/skills/sprint-start/scripts/sprint-teardown.sh {N}
|
||||
```
|
||||
|
||||
This removes all worktrees under `.sprint/sprint-{N}/` and prunes git
|
||||
metadata. Safe to skip if the sprint didn't use ephemeral worktrees
|
||||
(e.g. legacy persistent worktree setup).
|
||||
|
||||
#### A2. Bump the version
|
||||
|
||||
The project version scheme is `v0.1.{sprint_number}`. After closing
|
||||
@@ -197,10 +209,20 @@ If everything looks ready, activate the sprint:
|
||||
tooling/db/sprint start
|
||||
```
|
||||
|
||||
Then report:
|
||||
Then open team terminal tabs automatically:
|
||||
|
||||
```bash
|
||||
.claude/skills/sprint-start/scripts/start-sprint.sh
|
||||
```
|
||||
|
||||
This creates ephemeral worktrees under `.sprint/sprint-{N}/{team}/` for
|
||||
each team with open tickets, and opens Ptyxis windows with tmux + Claude
|
||||
auto-starting in each tab. The user will have one tab per active team.
|
||||
|
||||
Report:
|
||||
- Sprint activated (name, ticket count per team)
|
||||
- Remind the user to switch to a team branch and run `/sprint-start`
|
||||
there (or `cd` into the relevant worktree)
|
||||
- Worktrees created and tabs opened
|
||||
- Each team tab runs `/sprint-start` to load briefing and spawn agents
|
||||
|
||||
---
|
||||
|
||||
@@ -215,6 +237,9 @@ to plan the next sprint.
|
||||
|
||||
### 2. Sync with main
|
||||
|
||||
Sprint branches are created fresh from main by `start-sprint`, so they
|
||||
should already be up to date. If main has moved since branch creation:
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
git merge origin/main --no-edit
|
||||
@@ -329,14 +354,14 @@ Task(
|
||||
team_name: "sprint-{N}-{team}",
|
||||
name: "{name_lowercase}",
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `{team}`
|
||||
Branch: `sprint-{N}/{team}`
|
||||
|
||||
RULES (NON-NEGOTIABLE):
|
||||
|
||||
0. TEAM BOUNDARY: Your team is `{team}` ($WORKTREE_TEAM). Stay
|
||||
within the current working directory. Do NOT navigate to
|
||||
parent or sibling directories. Do NOT follow .git pointers
|
||||
to other directories.
|
||||
0. TEAM SCOPE: Your team is `{team}` on branch `sprint-{N}/{team}`.
|
||||
You may modify files in: {team_scope_dirs}
|
||||
You may read (but not modify): docs/, decisions/, wiki/, .claude/
|
||||
Do NOT modify files belonging to other teams.
|
||||
|
||||
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
@@ -487,12 +512,21 @@ When all tasks are complete (TaskList shows all completed):
|
||||
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
|
||||
#### 9b. Wait for review
|
||||
|
||||
Run `/pr-review` to spawn temporary reviewers. Wait for results.
|
||||
Do NOT run `/pr-review` from the team window — PR reviews run from
|
||||
the `main` branch (a separate window/session). The team window stays
|
||||
on its sprint branch.
|
||||
|
||||
After pushing and creating the PR, report the PR number to the user
|
||||
and stop. Wait for review feedback to arrive (the user or the main
|
||||
session will relay it, or it will appear as Gitea PR comments).
|
||||
|
||||
#### 9c. Handle review outcome
|
||||
|
||||
When review feedback arrives (from the user, main session, or PR
|
||||
comments):
|
||||
|
||||
**If CHANGES_REQUESTED:**
|
||||
|
||||
1. Parse the review comment table (from the Gitea PR comment or the
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Clean up ephemeral worktrees for a closed sprint.
|
||||
# Usage: sprint-teardown.sh <sprint-number>
|
||||
#
|
||||
# Removes all worktrees under .sprint/sprint-{N}/ and prunes git metadata.
|
||||
# Safe to run multiple times — skips already-removed worktrees.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
SPRINT_BASE="$(dirname "$REPO_ROOT")/.sprint"
|
||||
SPRINT=${1:?Usage: sprint-teardown.sh <sprint-number>}
|
||||
|
||||
SPRINT_DIR="$SPRINT_BASE/sprint-${SPRINT}"
|
||||
|
||||
if [ ! -d "$SPRINT_DIR" ]; then
|
||||
echo "No worktrees found for sprint-${SPRINT} (directory $SPRINT_DIR does not exist)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Cleaning sprint-${SPRINT} worktrees..."
|
||||
|
||||
for wt in "$SPRINT_DIR"/*/; do
|
||||
[ -d "$wt" ] || continue
|
||||
team="$(basename "$wt")"
|
||||
echo " Removing: $team"
|
||||
git -C "$REPO_ROOT" worktree remove "$wt" --force 2>/dev/null || echo " (already removed or dirty)"
|
||||
done
|
||||
|
||||
rmdir "$SPRINT_DIR" 2>/dev/null || true
|
||||
git -C "$REPO_ROOT" worktree prune
|
||||
|
||||
echo "Done. Sprint-${SPRINT} worktrees cleaned."
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# Opens Ptyxis tabs for the active sprint teams in the CURRENT window.
|
||||
# Auto-starts Claude Code in each tab via tmux.
|
||||
#
|
||||
# Usage: start-sprint.sh [sprint-number]
|
||||
# If omitted, auto-detects the active sprint from the database.
|
||||
#
|
||||
# Assumes the caller is already on main in the current terminal.
|
||||
# Adds one tab per active team — no duplicate main tab.
|
||||
#
|
||||
# Worktrees are created under .sprint/ (ephemeral, cleaned after sprint close).
|
||||
# Can be called from any directory — resolves paths from the script location.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# Script lives at .claude/skills/sprint-start/scripts/ — repo root is 4 levels up
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
# Parent of repo root is where .sprint/ and the DB live
|
||||
PARENT="$(dirname "$REPO_ROOT")"
|
||||
|
||||
# Resolve sprint number — argument or active sprint from DB
|
||||
if [ -n "${1:-}" ]; then
|
||||
SPRINT="$1"
|
||||
else
|
||||
SPRINT=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT id FROM sprints WHERE status='active'" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['rows'][0]['id'])" 2>/dev/null || true)
|
||||
if [ -z "$SPRINT" ]; then
|
||||
echo "error: no active sprint found. Pass a sprint number or activate a sprint first." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Query teams with open tickets
|
||||
TEAMS=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT DISTINCT team FROM tickets WHERE sprint_id=$SPRINT AND status NOT IN ('done','cancelled')" 2>/dev/null | python3 -c "import sys,json; [print(r['team']) for r in json.load(sys.stdin)['rows']]" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$TEAMS" ]; then
|
||||
echo "Sprint $SPRINT has no open tickets. Opening main only."
|
||||
fi
|
||||
|
||||
echo "Sprint $SPRINT — teams: ${TEAMS:-none}"
|
||||
|
||||
# ── Open team tabs in the CURRENT window ────────────────────────────
|
||||
# No separate main tab — the caller is already on main.
|
||||
# All team tabs open as --tab in the active Ptyxis window.
|
||||
for team in $TEAMS; do
|
||||
BRANCH="sprint-${SPRINT}/${team}"
|
||||
WDIR="$PARENT/.sprint/sprint-${SPRINT}/${team}"
|
||||
|
||||
# Create worktree if it doesn't exist
|
||||
if [ ! -d "$WDIR" ]; then
|
||||
echo "Creating worktree: $WDIR (branch: $BRANCH)"
|
||||
mkdir -p "$(dirname "$WDIR")"
|
||||
# Create branch from main if it doesn't exist remotely
|
||||
if git -C "$REPO_ROOT" rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
|
||||
git -C "$REPO_ROOT" worktree add "$WDIR" "$BRANCH"
|
||||
else
|
||||
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WDIR" HEAD
|
||||
fi
|
||||
fi
|
||||
|
||||
ptyxis --tab -d "$WDIR" -x 'tmux new-session \; send-keys "claude /sprint-start" Enter'
|
||||
sleep 0.3
|
||||
done
|
||||
|
||||
echo "Session ready. Main + ${TEAMS:-(no teams)}"
|
||||
@@ -1,125 +0,0 @@
|
||||
---
|
||||
name: worktree-update
|
||||
description: >
|
||||
Sync worktree branches with main. Use when the user says "update worktrees",
|
||||
"sync branches", "merge main", "worktree update", or invokes /worktree-update.
|
||||
When on main: shows which worktree branches are ahead and lets the user pick
|
||||
which to merge into main (flags branches with open PRs). When on a non-main
|
||||
branch: merges main into the current branch. All operations are non-destructive.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, AskUserQuestion
|
||||
---
|
||||
|
||||
# Worktree Update Skill
|
||||
|
||||
Sync worktree branches safely. Direction depends on the current branch.
|
||||
|
||||
## Safety Rules (NON-NEGOTIABLE)
|
||||
|
||||
- **Never force-push, reset --hard, rebase, or delete branches.**
|
||||
- **Never use `--no-verify` or skip hooks.**
|
||||
- **Always use `--no-edit` on merges** to avoid interactive editor prompts.
|
||||
- **Stop on merge conflicts** — report them and let the user decide. Never
|
||||
auto-resolve or abort a conflicted merge without asking.
|
||||
- **Fetch before comparing** — always `git fetch --all` first so commit
|
||||
comparisons are accurate.
|
||||
- **Dry-run first on main** — show the user exactly what will happen before
|
||||
merging anything into main.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Detect current branch
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
Branch determines the mode: `main` → outbound sync, anything else → inbound sync.
|
||||
|
||||
### 2a. On `main` — merge worktree branches into main
|
||||
|
||||
#### Fetch and compare
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
```
|
||||
|
||||
Discover all worktree branches (excluding `main` itself):
|
||||
|
||||
```bash
|
||||
git worktree list | grep -v '\[main\]' | sed 's/.*\[//;s/\]//'
|
||||
```
|
||||
|
||||
For each worktree branch, check if it has commits ahead of main:
|
||||
|
||||
```bash
|
||||
git rev-list --count main..origin/<branch>
|
||||
```
|
||||
|
||||
Skip branches with 0 commits ahead. For branches that ARE ahead, collect:
|
||||
- Branch name
|
||||
- Number of commits ahead
|
||||
- One-line log of those commits: `git log --oneline main..<branch>`
|
||||
|
||||
#### Check for open PRs
|
||||
|
||||
```bash
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
Cross-reference open PR head branches with the ahead-of-main branches.
|
||||
|
||||
#### Present results
|
||||
|
||||
Show a summary table of branches ahead of main. For each branch, indicate:
|
||||
- `[PR]` if it has an open pull request — warn that it should go through
|
||||
normal review channels (use `/pr-review` instead)
|
||||
- Commit count and summary
|
||||
|
||||
Use `AskUserQuestion` to let the user pick which branches to merge.
|
||||
Exclude PR-flagged branches from the default options (but allow the user to
|
||||
override via "Other").
|
||||
|
||||
#### Merge selected branches
|
||||
|
||||
For each selected branch, one at a time:
|
||||
|
||||
```bash
|
||||
git merge <branch> --no-edit
|
||||
```
|
||||
|
||||
If a merge conflicts, **stop immediately**. Report the conflict and do NOT
|
||||
continue to the next branch. The user must resolve before proceeding.
|
||||
|
||||
After all merges, show the final state with `git log --oneline -N` (where N
|
||||
covers the new commits).
|
||||
|
||||
#### Backup the shared database
|
||||
|
||||
After successful merges on main, snapshot the database for git tracking:
|
||||
|
||||
```bash
|
||||
make db-backup
|
||||
```
|
||||
|
||||
This copies the shared `settledreach.db` (in the parent directory) to
|
||||
`docs/backups/settledreach.db.backup`. Stage and commit it with the merge
|
||||
if the file changed.
|
||||
|
||||
### 2b. Not on `main` — merge main into current branch
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
git merge origin/main --no-edit
|
||||
```
|
||||
|
||||
If clean, report the result (fast-forward or merge commit, files changed).
|
||||
If conflicts, report them and stop.
|
||||
|
||||
### 3. Push
|
||||
|
||||
After a successful merge, push the branch:
|
||||
|
||||
```bash
|
||||
git push origin <current-branch>
|
||||
```
|
||||
@@ -14,10 +14,12 @@ REMOTE_REF="origin/$BRANCH"
|
||||
if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; then
|
||||
CLIENT_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- client/ 2>/dev/null | wc -l)
|
||||
SERVER_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- server/ 2>/dev/null | wc -l)
|
||||
TOOLING_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- tooling/ pyproject.toml 2>/dev/null | wc -l)
|
||||
else
|
||||
# New branch or no remote ref — fall through to directory checks
|
||||
CLIENT_CHANGED=1
|
||||
SERVER_CHANGED=1
|
||||
TOOLING_CHANGED=1
|
||||
fi
|
||||
|
||||
# --- GDScript parse check (headless Godot) ---
|
||||
@@ -97,6 +99,20 @@ else
|
||||
echo "pre-push: WARNING — cargo not found or server/ missing, skipping Rust lint"
|
||||
fi
|
||||
|
||||
# --- Python lint (ruff) ---
|
||||
if [ "$TOOLING_CHANGED" -eq 0 ]; then
|
||||
echo "pre-push: no tooling/ changes — skipping Python lint"
|
||||
elif command -v ruff >/dev/null 2>&1 && [ -d "$REPO_ROOT/tooling" ]; then
|
||||
echo "pre-push: checking Python (ruff)..."
|
||||
if ! (cd "$REPO_ROOT" && ruff check tooling/ 2>&1); then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "pre-push: ruff — OK"
|
||||
fi
|
||||
else
|
||||
echo "pre-push: skipping Python lint (ruff not found — install with: pip install 'ruff>=0.9')"
|
||||
fi
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "pre-push: $ERRORS check(s) failed. Push aborted."
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
class-definitions-order:
|
||||
- tools
|
||||
- classnames
|
||||
- extends
|
||||
- docstrings
|
||||
- signals
|
||||
- enums
|
||||
- consts
|
||||
- staticvars
|
||||
- exports
|
||||
- pubvars
|
||||
- prvvars
|
||||
- onreadypubvars
|
||||
- onreadyprvvars
|
||||
- others
|
||||
class-load-variable-name: (([A-Z][a-z0-9]*)+|_?[a-z][a-z0-9]*(_[a-z0-9]+)*)
|
||||
class-name: ([A-Z][a-z0-9]*)+
|
||||
class-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
|
||||
comparison-with-itself: null
|
||||
constant-name: _?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*
|
||||
disable: []
|
||||
duplicated-load: null
|
||||
enum-element-name: '[A-Z][A-Z0-9]*(_[A-Z0-9]+)*'
|
||||
enum-name: ([A-Z][a-z0-9]*)+
|
||||
excluded_directories: !!set
|
||||
.git: null
|
||||
addons: null
|
||||
expression-not-assigned: null
|
||||
function-argument-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
|
||||
function-arguments-number: 10
|
||||
function-name: (_on_([A-Z][a-z0-9]*)+(_[a-z0-9]+)*|_?[a-z][a-z0-9]*(_[a-z0-9]+)*)
|
||||
function-preload-variable-name: ([A-Z][a-z0-9]*)+
|
||||
function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
|
||||
load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*)
|
||||
loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
|
||||
max-file-lines: 1000
|
||||
max-line-length: 120
|
||||
max-public-methods: 200
|
||||
max-returns: 6
|
||||
mixed-tabs-and-spaces: null
|
||||
no-elif-return: null
|
||||
no-else-return: null
|
||||
signal-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
|
||||
sub-class-name: _?([A-Z][a-z0-9]*)+
|
||||
tab-characters: 1
|
||||
trailing-whitespace: null
|
||||
unnecessary-pass: null
|
||||
unused-argument: null
|
||||
@@ -6,6 +6,51 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.31] — 2026-04-05
|
||||
|
||||
### Added
|
||||
- Star map click-through popup with GTTR excerpt, system profile, and adjacent systems (#780)
|
||||
- Edge-only-on-selected visibility rule for star map — default shows no edges, selected system shows adjacents only (#780)
|
||||
- Cultural diversity sweep — 10 systems enriched, Afrikaans/Dutch subversions, D-168 Iserlohn IP evaluation (#766, #773)
|
||||
- Per-body GTTR entries for 25 moons and stations at hub systems (#782)
|
||||
- Shared Python module `tooling/db/common.py` — centralizes DB path resolution, config loading, and WAL-mode connection setup (#777)
|
||||
- `ensure_venv()` auto-activation for Python tooling scripts with third-party dependencies (#777)
|
||||
- `make lint-python` target running ruff on `tooling/` (#777)
|
||||
- Python/ruff lint block in pre-push hook (#777)
|
||||
- Zero-warnings policy in pr-push and pr-review skills; `.gdlintrc` with max-line-length 120
|
||||
|
||||
### Changed
|
||||
- Decomposed main.gd (28KB → 13.4KB) into SnapshotConsumers, DialogueCoordinator, SnapshotHandler (#775)
|
||||
- Split monolithic `atlas.rs` (2119 lines) into 8 focused modules under `src/bin/atlas/` (#776)
|
||||
- Replaced persistent team worktrees with ephemeral sprint branches (`sprint-{N}/{team}`)
|
||||
- `start-sprint.sh` integrated into sprint-start skill — auto-creates worktrees and opens Ptyxis tabs
|
||||
|
||||
### Fixed
|
||||
- All 354 gdlint warnings resolved — zero warnings across all linters (#783)
|
||||
- Nova Estrada and Entremeio wiki entries enriched to batch standard (#774)
|
||||
- Auto-unassign done tickets on sprint close (`tooling/db/sprint stop`)
|
||||
- Pre-existing `NameError` in `decisions_sync.py` resolved via shared module extraction (#777)
|
||||
- Unused imports removed from `assign-astro-ids.py`, `generate-star-map.py`, `test_quaternius_raw.py` (#777)
|
||||
|
||||
## [v0.1.30] — 2026-04-05
|
||||
|
||||
### Added
|
||||
- `corridor-status` subcommand for atlas CLI — shows remaining unfinished systems grouped by geographic sector and hop distance (#744)
|
||||
- Star map insert module — concentric hop-ring view of 301 systems, sector-colored, click-to-select with info panel, pan/zoom (#674)
|
||||
- BoneAttachment3D overhead anchor above Head bone for future floating UI elements (#712)
|
||||
- CharacterVisualDescriptor wired into startup IPC and snapshot restore for save/load persistence (#718)
|
||||
- Display-only hair highlight swatch (auto-derived from primary tint) in character creation (#719)
|
||||
- Asset manifest fully populated (11 body types, 14 hair, 4 heads, 4 eyebrows, 8 clothing) with regeneration script (#720)
|
||||
- Sprint 30 acceptance test suite (27 tests across all 5 tickets)
|
||||
|
||||
### Fixed
|
||||
- `habitable_planet_count` filter now accepts both "breathable" and "standard" atmosphere values — previously all committed systems reported 0 habitable planets (#762). Systems committed before this fix may have stale `habitable_planet_count = 0`; re-commit to update.
|
||||
- `corridor-status` uses LEFT JOIN so systems without gate records are included in counts
|
||||
- `generate_body_matrix` now emits `atmosphere: "standard"` (was "breathable") to match committed-system conventions
|
||||
- DirAccess asset scanning replaced with manifest JSON — fixes character creation in exported PCK builds (#720)
|
||||
- Star map set_insert_active() no longer auto-shows the modal panel (#674)
|
||||
- Star map insert state propagation wired into main.gd (#674)
|
||||
|
||||
## [v0.1.29] — 2026-04-03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -44,18 +44,17 @@ Development follows a strict cascade. Each phase has a concrete deliverable. **D
|
||||
|
||||
### Team boundaries
|
||||
|
||||
**Your team identity is `$WORKTREE_TEAM`.** All work must stay within the current working directory.
|
||||
**Your team is determined by your sprint branch** (e.g. `sprint-31/server` → server team).
|
||||
|
||||
- All file paths are relative to the current working directory (e.g. `server/src/bridge/types.rs`).
|
||||
- **Do NOT navigate to parent or sibling directories** (`../`, `../client/`, etc.) unless explicitly instructed. Do NOT use absolute paths to reach other team directories.
|
||||
- **Do NOT write auto-memory files for other teams.** If `$WORKTREE_TEAM` is `server`, do not write to memory paths containing `client`, `main`, etc.
|
||||
- For context: each team has its own directory via git worktrees, sharing a parent directory (`settled-reach/`). The `.git` file points to a shared git directory — do not follow it to determine your working root.
|
||||
- **Exception — stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own team only** (e.g. `main/.git/worktrees/$WORKTREE_TEAM/index.lock`). Never touch lock files belonging to other teams.
|
||||
- **Never chain git commands** in a single Bash call (e.g. `git add ... && git commit ...`). The shared `.git` directory means concurrent index access from the same terminal creates `index.lock` collisions. Always run `git add` and `git commit` as **separate sequential Bash calls**.
|
||||
- All file paths are relative to the repo root (e.g. `server/src/bridge/types.rs`).
|
||||
- **Stay within your team's scope.** Server team modifies `server/`. Client team modifies `client/`. Copy team modifies `wiki/`, `docs/atlas/`, `content/`. Shared directories (`docs/`, `decisions/`) are readable by all teams.
|
||||
- **Do NOT modify files outside your team scope** unless the ticket explicitly requires it.
|
||||
- **Never chain git commands** in a single Bash call (e.g. `git add ... && git commit ...`). Always run `git add` and `git commit` as **separate sequential Bash calls**.
|
||||
- **Stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file at `.git/index.lock` (or `.git/worktrees/<name>/index.lock` if in a worktree).
|
||||
|
||||
### Database
|
||||
|
||||
The ticketing database (`settledreach.db`) lives in the **parent directory** shared across all worktrees — it is not tracked in git. A backup is committed to `docs/backups/settledreach.db.backup` via main only.
|
||||
The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var (set in `.claude/settings.json`). A backup is committed to `docs/backups/settledreach.db.backup` via main only.
|
||||
|
||||
### Before starting work
|
||||
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
|
||||
@@ -84,10 +83,20 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
||||
- Three test tiers: (1) Live server — highest fidelity, (2) MessagePack replay via `Protocol.decode_snapshot()` — for unreachable rooms, (3) TestHarness mock — for UI-only tests where fog data doesn't matter.
|
||||
- `make fixtures-gauntlet` regenerates real server snapshot fixtures from the Gauntlet world.
|
||||
|
||||
### GDScript conventions
|
||||
|
||||
**Autoload parse-order rule:** Autoload scripts (`client/scripts/autoloads/`) compile before global `class_name` scripts are registered. Referencing a `class_name` type directly in an autoload causes a parse-time "not declared" error. Pattern:
|
||||
- Declare fields untyped: `var my_field = null` (comment the intended type)
|
||||
- Do **not** reference `class_name` types at the top level or in `_ready()` of autoloads
|
||||
- In method bodies called at runtime (e.g. `apply_snapshot`), use `load()` inline — by then the script is cached and `load()` returns the cached resource without reloading: `var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")`
|
||||
- Do **not** cache the `load()` result in `_ready()` — `_ready()` fires during autoload init, before the target script is in the resource cache, causing an actual file reload that breaks self-references in scripts using their own `class_name`
|
||||
|
||||
`game_state.gd` (`character_visual_descriptor` field) and `sim_bridge.gd` (`harness` field) follow this pattern.
|
||||
|
||||
### 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:** `tooling/db/decision claim D <domain> "title"` — prevents ID collisions across worktrees
|
||||
- **Claim IDs before writing:** `tooling/db/decision claim D <domain> "title"` — prevents ID collisions across parallel branches
|
||||
- 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
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
|
||||
.PHONY: help setup build check-protocol client server game stop test lint ci ci-client ci-server clean \
|
||||
.PHONY: help setup build check-protocol client server game stop test lint lint-python setup-venv ci ci-client ci-server clean \
|
||||
decisions-sync decisions-coverage decisions-active decisions-orphan \
|
||||
db-backup db-install validate-content check-fact-ids setup-hooks \
|
||||
audit atlas-verify \
|
||||
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
|
||||
pre-pr-server pre-pr-client pre-pr-content \
|
||||
fixtures-client fixtures-gauntlet golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
checklist-validate checklist-generate check-star-map \
|
||||
build-sr-voice run-sr-voice test-voice-mock test-voice-real \
|
||||
perf-baseline debug-schedule \
|
||||
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
|
||||
screenshot visual-movie test-visual visual-update
|
||||
screenshot visual-movie test-visual visual-update \
|
||||
manifest
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -21,7 +22,8 @@ GODOT_VERSION ?= 4.6
|
||||
help:
|
||||
@echo "The Settled Reach — Development Commands"
|
||||
@echo ""
|
||||
@echo " make setup Install dev dependencies (Rust, Godot, tooling)"
|
||||
@echo " make setup Install dev dependencies (Rust, Godot, tooling, venv)"
|
||||
@echo " make setup-venv Create .venv and install Python tooling deps"
|
||||
@echo " make build Build client and server"
|
||||
@echo " make game Build and run the full game (server + client)"
|
||||
@echo " make stop Stop any running server instance"
|
||||
@@ -32,7 +34,8 @@ help:
|
||||
@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 lint Run all linters (server, client, python)"
|
||||
@echo " make lint-python Run ruff on tooling/"
|
||||
@echo " make ci Run full CI pipeline locally"
|
||||
@echo " make ci-client Run client CI checks"
|
||||
@echo " make ci-server Run server CI checks"
|
||||
@@ -62,6 +65,7 @@ help:
|
||||
@echo " make test-visual Run visual golden regression tests"
|
||||
@echo " make visual-update Regenerate visual goldens and stage for commit"
|
||||
@echo ""
|
||||
@echo " make manifest Regenerate assets/characters/manifest.json from asset dirs (#720)"
|
||||
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
|
||||
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
|
||||
@echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)"
|
||||
@@ -80,7 +84,7 @@ help:
|
||||
|
||||
# --- Setup ---
|
||||
|
||||
setup: setup-rust setup-godot setup-tooling setup-hooks decisions-sync
|
||||
setup: setup-rust setup-godot setup-tooling setup-venv setup-hooks decisions-sync
|
||||
@echo "Dev environment ready."
|
||||
|
||||
setup-rust:
|
||||
@@ -101,6 +105,11 @@ setup-hooks:
|
||||
@git config core.hooksPath .config/hooks
|
||||
@echo "Git hooks path set to .config/hooks"
|
||||
|
||||
setup-venv:
|
||||
@python3 -m venv .venv
|
||||
@.venv/bin/pip install -e ".[dev]" --quiet
|
||||
@echo "Venv ready at .venv — activate with: source .venv/bin/activate"
|
||||
|
||||
# --- Build ---
|
||||
|
||||
check-protocol:
|
||||
@@ -220,7 +229,10 @@ clean-imports:
|
||||
|
||||
# --- Lint ---
|
||||
|
||||
lint: lint-server lint-client
|
||||
lint: lint-server lint-client lint-python
|
||||
|
||||
lint-python:
|
||||
ruff check tooling/
|
||||
|
||||
lint-server:
|
||||
cd server && cargo clippy -- -D warnings
|
||||
@@ -345,6 +357,9 @@ checklist-validate:
|
||||
checklist-generate:
|
||||
@tooling/validate-checklist
|
||||
|
||||
check-star-map:
|
||||
@python3 tooling/generate-star-map-data.py --check
|
||||
|
||||
perf-baseline:
|
||||
@tooling/perf-baseline
|
||||
|
||||
@@ -399,6 +414,12 @@ test-voice-real:
|
||||
cd server && cargo test --test voice_pipeline -- --nocapture
|
||||
@echo "Results: .tmp/voice-test/results.txt"
|
||||
|
||||
# --- Asset manifest ---
|
||||
|
||||
manifest:
|
||||
@tooling/generate-character-manifest
|
||||
@echo "Manifest regenerated — commit client/assets/characters/manifest.json if changed."
|
||||
|
||||
# --- Clean ---
|
||||
|
||||
clean:
|
||||
|
||||
@@ -1,13 +1,75 @@
|
||||
{
|
||||
"body_types": ["average_m", "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f"],
|
||||
"heads": [],
|
||||
"hair": ["bob", "buns", "buzzed", "long", "ponytail", "bald"],
|
||||
"facial_hair": ["beard", "moustache", "mutton_chops"],
|
||||
"eyebrows": [],
|
||||
"body_types": [
|
||||
"average_f",
|
||||
"average_m",
|
||||
"child",
|
||||
"heavy_f",
|
||||
"heavy_m",
|
||||
"muscular_f",
|
||||
"muscular_m",
|
||||
"teen_f",
|
||||
"teen_m",
|
||||
"thin_f",
|
||||
"thin_m"
|
||||
],
|
||||
"heads": [
|
||||
"head_001",
|
||||
"head_002",
|
||||
"head_003",
|
||||
"head_004"
|
||||
],
|
||||
"hair": [
|
||||
"bald",
|
||||
"balding",
|
||||
"bob",
|
||||
"buns",
|
||||
"buzzed",
|
||||
"buzzed_female",
|
||||
"dreads",
|
||||
"long",
|
||||
"long_dreads",
|
||||
"mohawk",
|
||||
"ponytail",
|
||||
"ponytail_f",
|
||||
"simple_parted",
|
||||
"slick_back"
|
||||
],
|
||||
"facial_hair": [
|
||||
"beard",
|
||||
"moustache",
|
||||
"mutton_chops"
|
||||
],
|
||||
"eyebrows": [
|
||||
"female",
|
||||
"regular",
|
||||
"teen",
|
||||
"thick"
|
||||
],
|
||||
"clothing": {
|
||||
"peasant_tunic": {"slot": "torso"},
|
||||
"peasant_pants": {"slot": "legs"},
|
||||
"peasant_shoes": {"slot": "feet"}
|
||||
"boots_work": {
|
||||
"slot": "feet"
|
||||
},
|
||||
"coveralls_basic": {
|
||||
"slot": "torso"
|
||||
},
|
||||
"jacket_utility": {
|
||||
"slot": "torso"
|
||||
},
|
||||
"pants_cargo": {
|
||||
"slot": "legs"
|
||||
},
|
||||
"peasant_pants": {
|
||||
"slot": "legs"
|
||||
},
|
||||
"peasant_shoes": {
|
||||
"slot": "feet"
|
||||
},
|
||||
"peasant_tunic": {
|
||||
"slot": "torso"
|
||||
},
|
||||
"shirt_henley": {
|
||||
"slot": "torso"
|
||||
}
|
||||
},
|
||||
"accessories": []
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ extends Node
|
||||
## No-op fallback when audio assets absent (D-038).
|
||||
## Spatial audio positioning for close-range sounds (D-018).
|
||||
|
||||
signal dip_changed(profile: String)
|
||||
|
||||
# --- D-067: Recognition chime asset key ---
|
||||
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
|
||||
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
|
||||
@@ -63,6 +65,22 @@ const ZONE_ASSETS: Dictionary = {
|
||||
"corridor": "amb_corridor_layer",
|
||||
}
|
||||
|
||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
|
||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||
# Maps server-sent sound event_type strings to audio asset keys.
|
||||
# Keys match filename stems in res://assets/audio/ (scanned by _scan_registry).
|
||||
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||
"Footstep": "sfx_footstep_metal_walk",
|
||||
"FootstepWalk": "sfx_footstep_metal_walk",
|
||||
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepSprint": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
}
|
||||
|
||||
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
|
||||
var _registry: Dictionary = {}
|
||||
|
||||
@@ -83,10 +101,6 @@ var _ambient_players: Dictionary = {}
|
||||
var _current_zone_id: String = ""
|
||||
var _zone_tweens: Array = []
|
||||
|
||||
signal dip_changed(profile: String)
|
||||
|
||||
|
||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_buses()
|
||||
@@ -200,21 +214,6 @@ func stop_all_loops() -> void:
|
||||
stop_loop(key)
|
||||
|
||||
|
||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||
# Maps server-sent sound event_type strings to audio asset keys.
|
||||
# Keys match filename stems in res://assets/audio/ (scanned by _scan_registry).
|
||||
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||
"Footstep": "sfx_footstep_metal_walk",
|
||||
"FootstepWalk": "sfx_footstep_metal_walk",
|
||||
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepSprint": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
}
|
||||
|
||||
|
||||
## Play a close-range sound event at a world tile position (D-018, #125).
|
||||
## event_type: server RangeCategory::Close event type string (e.g. "Footstep").
|
||||
## world_tile_pos: server tile coordinates — converted to world pixels internally.
|
||||
|
||||
@@ -8,7 +8,8 @@ extends Node
|
||||
# Used by fog shader to distinguish visual treatment per tile.
|
||||
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
||||
const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569). Retained — tests still reference it.
|
||||
const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569).
|
||||
# Retained — tests still reference it.
|
||||
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||
|
||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||
@@ -35,6 +36,14 @@ var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
|
||||
var visibility_texture: ImageTexture
|
||||
var exploration_texture: ImageTexture
|
||||
var zone_tint_texture: ImageTexture
|
||||
## Debug flag — when true, fog.gdshader renders raw exploration texture
|
||||
## as colored overlay (green=visible, blue=explored, red=unexplored).
|
||||
## Toggle via FogState.debug_exploration = true in the console.
|
||||
var debug_exploration: bool = false
|
||||
## Deterministic shader time for visual test captures.
|
||||
## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec().
|
||||
## Set before settle frames so noise phase is reproducible across runs.
|
||||
var override_time: float = -1.0
|
||||
|
||||
var _vis_bytes: PackedByteArray
|
||||
var _exp_bytes: PackedByteArray
|
||||
@@ -47,16 +56,6 @@ var _width: int = 1
|
||||
var _height: int = 1
|
||||
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
|
||||
|
||||
## Debug flag — when true, fog.gdshader renders raw exploration texture
|
||||
## as colored overlay (green=visible, blue=explored, red=unexplored).
|
||||
## Toggle via FogState.debug_exploration = true in the console.
|
||||
var debug_exploration: bool = false
|
||||
|
||||
## Deterministic shader time for visual test captures.
|
||||
## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec().
|
||||
## Set before settle frames so noise phase is reproducible across runs.
|
||||
var override_time: float = -1.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_resize(Rect2i(0, 0, 64, 64))
|
||||
|
||||
@@ -19,7 +19,8 @@ var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
var visible_tiles: Array = []
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
|
||||
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) — visible in fog but not explored
|
||||
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585)
|
||||
# — visible in fog but not explored
|
||||
|
||||
# v2 fields (D-015, D-031)
|
||||
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
|
||||
@@ -50,7 +51,8 @@ var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
|
||||
var player_inventory: Array = [] # [{item_id, name, slot}]
|
||||
|
||||
# v7 fields (#435, D-061/D-062)
|
||||
var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]} or null
|
||||
var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]}
|
||||
# or null
|
||||
|
||||
# D-064: true while dialogue box is visible or fading out (300ms).
|
||||
# InputMapper suppresses movement when this is true.
|
||||
@@ -100,7 +102,8 @@ var character_archetype: String = "detective"
|
||||
# #705: Character visual descriptor — set by character_creation.gd on confirmation.
|
||||
# Passed to EntityRenderer for the player entity's CharacterVisual on game start.
|
||||
# Null when no custom appearance has been selected (fallback: default descriptor).
|
||||
var character_visual_descriptor: CharacterVisualDescriptor = null
|
||||
# Type is CharacterVisualDescriptor — untyped to avoid autoload parse-order issue.
|
||||
var character_visual_descriptor = null
|
||||
|
||||
# #646: AI-Enhanced Dialogue enabled state (D-138).
|
||||
# Runtime toggle — true means the LLM re-voicing pipeline should run (server-side).
|
||||
@@ -151,8 +154,7 @@ var close_sound_events: Array = []
|
||||
# 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
|
||||
# DEPRECATED: _prev_player_position moved to SnapshotHandler (client-side accumulation fallback).
|
||||
|
||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||
# D-020: Read directly from snapshot "zone_id" field.
|
||||
@@ -161,263 +163,7 @@ var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previou
|
||||
var current_zone_id: String = ""
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
current_snapshot = snapshot
|
||||
|
||||
if snapshot.has("tick"):
|
||||
current_tick = snapshot.tick
|
||||
|
||||
if snapshot.has("entities"):
|
||||
visible_entities = snapshot.entities
|
||||
# Derive player position from the entity with kind.variant == "Player"
|
||||
var found_player := false
|
||||
for entity in visible_entities:
|
||||
if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player":
|
||||
player_position = Vector2(entity.x, entity.y)
|
||||
if entity.has("entity_id"):
|
||||
player_entity_id = entity.entity_id
|
||||
found_player = true
|
||||
break
|
||||
if not found_player and visible_entities.size() > 0:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
visible_entities.size()])
|
||||
|
||||
# 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:
|
||||
# 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"
|
||||
if snapshot.has("tiles"):
|
||||
visible_tiles = snapshot.tiles
|
||||
elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
# Live server: visible_tiles now includes type from tile_kind field
|
||||
var has_type := false
|
||||
if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary:
|
||||
has_type = snapshot.visible_tiles[0].has("type")
|
||||
if has_type:
|
||||
visible_tiles = snapshot.visible_tiles
|
||||
|
||||
if snapshot.has("visible_positions"):
|
||||
visible_positions.clear()
|
||||
for pos in snapshot.visible_positions:
|
||||
visible_positions[Vector2i(pos.x, pos.y)] = true
|
||||
|
||||
# v2: game_time (D-031)
|
||||
if snapshot.has("game_time") and snapshot.game_time is Dictionary:
|
||||
game_time = snapshot.game_time
|
||||
|
||||
# v2: player_facing (D-015)
|
||||
if snapshot.has("player_facing") and snapshot.player_facing is String:
|
||||
player_facing = snapshot.player_facing
|
||||
|
||||
# v4: nearby_interactions (#404/#405)
|
||||
if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array:
|
||||
nearby_interactions = snapshot.nearby_interactions
|
||||
else:
|
||||
nearby_interactions = []
|
||||
|
||||
# v5: current_monologue (#414)
|
||||
if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary:
|
||||
current_monologue = snapshot.current_monologue
|
||||
else:
|
||||
current_monologue = null
|
||||
|
||||
# #122: lattice_profile — character insert capability level for monologue colour
|
||||
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
|
||||
lattice_profile = snapshot.lattice_profile
|
||||
|
||||
# v6: player_stance (#449, D-053)
|
||||
if snapshot.has("player_stance") and snapshot.player_stance is String:
|
||||
player_stance = snapshot.player_stance
|
||||
|
||||
# v6: player_inventory (#449, D-065)
|
||||
if snapshot.has("player_inventory") and snapshot.player_inventory is Array:
|
||||
player_inventory = snapshot.player_inventory
|
||||
else:
|
||||
player_inventory = []
|
||||
|
||||
# v7: current_dialogue (#434, D-061)
|
||||
if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary:
|
||||
current_dialogue = snapshot.current_dialogue
|
||||
else:
|
||||
current_dialogue = null
|
||||
|
||||
# v7: pending_recognitions (#431, D-059/D-060)
|
||||
if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array:
|
||||
pending_recognitions = snapshot.pending_recognitions
|
||||
else:
|
||||
pending_recognitions = []
|
||||
|
||||
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC lines
|
||||
if snapshot.has("conversation_events") and snapshot.conversation_events is Array:
|
||||
conversation_events = snapshot.conversation_events
|
||||
else:
|
||||
conversation_events = []
|
||||
|
||||
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended
|
||||
if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array:
|
||||
conversation_ended = snapshot.conversation_ended
|
||||
else:
|
||||
conversation_ended = []
|
||||
|
||||
# v8: dialogue_response (#305, D-028) — NPC follow-up after player choice
|
||||
if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary:
|
||||
dialogue_response = snapshot.dialogue_response
|
||||
else:
|
||||
dialogue_response = null
|
||||
|
||||
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
|
||||
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
||||
gauntlet_mode = true
|
||||
else:
|
||||
gauntlet_mode = false
|
||||
if snapshot.has("room_id") and snapshot.room_id is String:
|
||||
room_id = snapshot.room_id
|
||||
else:
|
||||
room_id = null
|
||||
|
||||
# OQ-07 (#522): insert_active — defaults true (v0.1 always has insert).
|
||||
# Server may send false for characters without an insert in future sprints.
|
||||
if snapshot.has("insert_active") and snapshot.insert_active is bool:
|
||||
insert_active = snapshot.insert_active
|
||||
else:
|
||||
insert_active = true
|
||||
|
||||
# #507: rng_seed — server sends current RNG seed for replay determinism.
|
||||
# Field: "rng_seed" (u64 as integer). Null if server does not include it.
|
||||
if snapshot.has("rng_seed"):
|
||||
rng_seed = snapshot.rng_seed
|
||||
else:
|
||||
rng_seed = null
|
||||
|
||||
# D-018: Sound events from server — partition by range_category.
|
||||
# #126: Medium → fog-edge directional indicators.
|
||||
# #125: Close → positional 2D audio via AudioManager.
|
||||
if snapshot.has("sound_events") and snapshot.sound_events is Array:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
for se in snapshot.sound_events:
|
||||
if not se is Dictionary:
|
||||
continue
|
||||
var rc: String = se.get("range_category", "")
|
||||
if rc == "Medium":
|
||||
medium_sound_events.append(se)
|
||||
elif rc == "Close":
|
||||
close_sound_events.append(se)
|
||||
else:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# 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
|
||||
|
||||
# v18: debug_response (#580) — debug console command result.
|
||||
if snapshot.has("debug_response") and snapshot.debug_response is Dictionary:
|
||||
debug_response = snapshot.debug_response
|
||||
else:
|
||||
debug_response = null
|
||||
|
||||
# v20: settings_response (#627, D-138) — one-shot settings ack/dump from server.
|
||||
# "full" kind → iterate settings array and hydrate matching fields.
|
||||
if snapshot.has("settings_response") and snapshot.settings_response is Dictionary:
|
||||
settings_response = snapshot.settings_response
|
||||
var sr: Dictionary = snapshot.settings_response
|
||||
if sr.get("kind") == "full":
|
||||
var sr_settings: Variant = sr.get("settings")
|
||||
if sr_settings is Array:
|
||||
for entry in sr_settings:
|
||||
if not entry is Dictionary:
|
||||
continue
|
||||
if entry.get("key") == "ai_dialogue.enabled":
|
||||
var val: Variant = entry.get("value")
|
||||
if val != null:
|
||||
ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val)
|
||||
else:
|
||||
settings_response = 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).
|
||||
# #585: BoundaryWall tiles go to boundary_positions — rendered in fog but not marked explored.
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
visibility_sectors.clear()
|
||||
var has_explicit_positions := snapshot.has("visible_positions")
|
||||
if not has_explicit_positions:
|
||||
visible_positions.clear()
|
||||
boundary_positions.clear()
|
||||
for vtile in snapshot.visible_tiles:
|
||||
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
|
||||
continue
|
||||
var pos := Vector2i(vtile.x, vtile.y)
|
||||
var vis_sector: String = vtile.get("visibility", "")
|
||||
if vtile.has("visibility"):
|
||||
visibility_sectors[pos] = vis_sector
|
||||
# #585: BoundaryWall tiles are margin tiles visible through fog but not persistently
|
||||
# explored — they don't update the player's exploration memory when they leave LOS.
|
||||
if vis_sector == "BoundaryWall":
|
||||
boundary_positions[pos] = true
|
||||
elif not has_explicit_positions:
|
||||
visible_positions[pos] = true
|
||||
|
||||
|
||||
# -- Helpers ------------------------------------------------------------------
|
||||
|
||||
## Extract a bool from a tagged-union {"Bool": true} or plain bool value.
|
||||
## Handles both serde encoding styles; emits push_warning on unrecognised format.
|
||||
static func _extract_bool_setting(key: String, val: Variant) -> bool:
|
||||
if val is bool:
|
||||
return val
|
||||
if val is Dictionary and val.has("Bool"):
|
||||
return bool(val["Bool"])
|
||||
push_warning("GameState: unexpected type for setting '%s': %s" % [key, str(val)])
|
||||
return false
|
||||
# Autoload parse-order: class_name types are not registered when autoloads compile.
|
||||
# load() returns the cached resource after the first call — essentially free per-tick.
|
||||
var SH := load("res://scripts/snapshot_handler.gd")
|
||||
SH.apply(snapshot)
|
||||
|
||||
@@ -30,14 +30,6 @@ enum Action {
|
||||
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
|
||||
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
|
||||
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
|
||||
var facing_angle: float = -PI / 2.0 # Default: North
|
||||
var facing_octant: String = "North" # Derived from facing_angle
|
||||
var _last_sent_octant: String = "North" # Track to avoid redundant sends
|
||||
|
||||
# Minimum milliseconds between movement commands, per stance.
|
||||
# Tuned so Walk feels like walking, Sprint feels fast but readable.
|
||||
const MOVE_INTERVAL_MS := {
|
||||
@@ -46,6 +38,14 @@ const MOVE_INTERVAL_MS := {
|
||||
"Careful": 600, # ~1.7/sec — deliberate, scanning
|
||||
"Crouch": 800, # 1.25/sec — creeping
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
|
||||
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
|
||||
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
|
||||
var facing_angle: float = -PI / 2.0 # Default: North
|
||||
var facing_octant: String = "North" # Derived from facing_angle
|
||||
var _last_sent_octant: String = "North" # Track to avoid redundant sends
|
||||
var _last_move_msec: int = 0
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ extends Node
|
||||
|
||||
# -- Power profile ------------------------------------------------------------
|
||||
|
||||
## Emitted when the detected power profile changes.
|
||||
signal power_profile_changed(old_profile: int, new_profile: int)
|
||||
|
||||
## High-level power classification. POWER_SAVER reserved for future OS API.
|
||||
enum PowerProfile {
|
||||
FULL = 0, # Plugged in (charged, charging, or no battery) — no restrictions
|
||||
@@ -26,18 +29,6 @@ enum PowerProfile {
|
||||
POWER_SAVER = 2 # System-level power-saver mode (future: no cross-platform API yet)
|
||||
}
|
||||
|
||||
## Emitted when the detected power profile changes.
|
||||
signal power_profile_changed(old_profile: int, new_profile: int)
|
||||
|
||||
## Current power profile. Updated by the 30-second poll timer.
|
||||
var power_profile: PowerProfile = PowerProfile.FULL
|
||||
|
||||
## Raw OS power_state integer from the last poll. 0 = unknown, 1 = on battery, etc.
|
||||
var raw_power_state: int = 0
|
||||
|
||||
## Battery charge percentage (0–100). -1 if not available or not on battery.
|
||||
var battery_percent: int = -1
|
||||
|
||||
## How often (seconds) to re-poll OS for power state changes.
|
||||
const POWER_POLL_INTERVAL := 30.0
|
||||
|
||||
@@ -48,6 +39,15 @@ const _POWER_STATE_NO_BATTERY := 2
|
||||
const _POWER_STATE_CHARGING := 3
|
||||
const _POWER_STATE_CHARGED := 4
|
||||
|
||||
## Current power profile. Updated by the 30-second poll timer.
|
||||
var power_profile: PowerProfile = PowerProfile.FULL
|
||||
|
||||
## Raw OS power_state integer from the last poll. 0 = unknown, 1 = on battery, etc.
|
||||
var raw_power_state: int = 0
|
||||
|
||||
## Battery charge percentage (0–100). -1 if not available or not on battery.
|
||||
var battery_percent: int = -1
|
||||
|
||||
|
||||
# -- Memory -------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,52 +1,37 @@
|
||||
extends Node
|
||||
|
||||
# Connection states
|
||||
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 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
|
||||
|
||||
# Transport layer (non-test mode)
|
||||
var _bridge: LocalBridge = null
|
||||
var _server: ServerProcess = null
|
||||
var server_port: int = 9876 # Default matches server's default bind address
|
||||
var server_path: String = "" # Path to server binary — set before connect_to_sim()
|
||||
|
||||
# Connection retry state — handles server startup delay (Critical fix #1)
|
||||
const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay
|
||||
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)")
|
||||
# Connection states
|
||||
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
|
||||
|
||||
# Connection retry state — handles server startup delay (Critical fix #1)
|
||||
const MAX_CONNECT_RETRIES: int = 20 # ~2 seconds at 60fps with 100ms delay
|
||||
const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
|
||||
|
||||
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
|
||||
# Handshake state (#556)
|
||||
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
|
||||
|
||||
func reset_test_state() -> void:
|
||||
if harness: harness.reset()
|
||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
||||
# Type is TestHarness — untyped to avoid autoload parse-order issue.
|
||||
var harness = null # Test simulation (D-020: game logic lives outside production client)
|
||||
var server_port: int = 9876 # Default matches server's default bind address
|
||||
var server_path: String = "" # Path to server binary — set before connect_to_sim()
|
||||
|
||||
func _test_snapshot() -> Dictionary:
|
||||
return harness.snapshot()
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
return harness.has_los(from, to)
|
||||
# Transport layer (non-test mode)
|
||||
var _bridge: LocalBridge = null
|
||||
var _server: ServerProcess = null
|
||||
var _connect_retries: int = 0
|
||||
var _retry_timer: float = 0.0
|
||||
var _handshake_start_usec: int = 0
|
||||
|
||||
var _test_tick: int:
|
||||
get: return harness.tick if harness else 0
|
||||
@@ -82,6 +67,24 @@ var _test_input_queue: Array:
|
||||
get: return harness.input_queue if harness else []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if test_mode:
|
||||
harness = load("res://scripts/protocol/test_harness.gd").new()
|
||||
print("SimBridge: Running in test mode (dynamic snapshot)")
|
||||
|
||||
|
||||
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
|
||||
|
||||
func reset_test_state() -> void:
|
||||
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)
|
||||
|
||||
|
||||
# -- Connection lifecycle ------------------------------------------------------
|
||||
|
||||
# Change connection state and emit signal
|
||||
@@ -143,7 +146,7 @@ func _try_connect() -> void:
|
||||
_bridge = null
|
||||
|
||||
# Poll transport layer every frame (non-test mode only)
|
||||
func _process(delta: float) -> void:
|
||||
func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
if test_mode:
|
||||
return
|
||||
|
||||
@@ -231,9 +234,10 @@ func _process(delta: float) -> void:
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Send startup message with world_seed (#175, D-010/D-029).
|
||||
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed, GameState.character_archetype)
|
||||
var startup_bytes := Protocol.encode_startup_message(
|
||||
GameState.world_seed, GameState.character_archetype, GameState.character_visual_descriptor)
|
||||
if startup_bytes.size() > 0:
|
||||
var send_err := _bridge.send_message(startup_bytes)
|
||||
if send_err != OK:
|
||||
|
||||
@@ -51,4 +51,4 @@ func reload() -> void:
|
||||
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
|
||||
## Delegates to YamlParser.parse_flat() (#560).
|
||||
static func _parse_yaml(text: String) -> Dictionary:
|
||||
return YamlParser.parse_flat(text)
|
||||
return load("res://scripts/util/yaml_parser.gd").parse_flat(text)
|
||||
|
||||
@@ -135,12 +135,13 @@ func reset() -> void:
|
||||
static func _warn_empty_ids(conditions: Array, path: String) -> void:
|
||||
for i in conditions.size():
|
||||
if conditions[i].get("id", "").is_empty():
|
||||
push_warning("ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
|
||||
push_warning(
|
||||
"ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
|
||||
|
||||
|
||||
# -- Condition evaluation ------------------------------------------------------
|
||||
|
||||
func _evaluate_condition(cond: Dictionary) -> bool:
|
||||
func _evaluate_condition(cond: Dictionary) -> bool: # gdlint:disable=max-returns
|
||||
match cond.get("condition_type", ""):
|
||||
"player_near":
|
||||
return _eval_player_near(cond)
|
||||
|
||||
+23
-23
@@ -59,24 +59,6 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
|
||||
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
|
||||
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
|
||||
|
||||
# D-033 color lookup by relationship string (#521)
|
||||
static func color_for_relationship(relationship: String) -> Color:
|
||||
match relationship:
|
||||
"Friendly": return ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest": return ENTITY_COLOR_POI
|
||||
"Hostile": return ENTITY_COLOR_HOSTILE
|
||||
"Unknown": return ENTITY_COLOR_UNKNOWN
|
||||
_: return ENTITY_COLOR_UNKNOWN
|
||||
|
||||
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
|
||||
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
|
||||
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
|
||||
match kind_variant:
|
||||
"Player": return ENTITY_COLOR_PLAYER
|
||||
"Object", "Terrain": return ENTITY_COLOR_OBJECT
|
||||
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
|
||||
_: return ENTITY_COLOR_OBJECT
|
||||
|
||||
# D-048/D-056: Insert-styled UI color palette
|
||||
# Used by dialogue box, interaction list, radial menu, and other diegetic insert UI.
|
||||
const INSERT_COLOR_TEXT: Color = Color("#c8d0e0") # Default insert text — white-blue
|
||||
@@ -96,11 +78,6 @@ 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)
|
||||
|
||||
@@ -115,3 +92,26 @@ const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — second
|
||||
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
|
||||
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
|
||||
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
|
||||
|
||||
# D-033 color lookup by relationship string (#521)
|
||||
static func color_for_relationship(relationship: String) -> Color:
|
||||
match relationship:
|
||||
"Friendly": return ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest": return ENTITY_COLOR_POI
|
||||
"Hostile": return ENTITY_COLOR_HOSTILE
|
||||
"Unknown": return ENTITY_COLOR_UNKNOWN
|
||||
_: return ENTITY_COLOR_UNKNOWN
|
||||
|
||||
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
|
||||
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
|
||||
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
|
||||
match kind_variant:
|
||||
"Player": return ENTITY_COLOR_PLAYER
|
||||
"Object", "Terrain": return ENTITY_COLOR_OBJECT
|
||||
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
|
||||
_: return ENTITY_COLOR_OBJECT
|
||||
|
||||
# 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]
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
class_name DialogueCoordinator
|
||||
## Dialogue snapshot consumers and signal handlers extracted from main.gd (#775).
|
||||
##
|
||||
## Owns NPC identity tracking state (_last_dialogue_npc_id/name) shared between
|
||||
## consuming dialogue snapshots and handling dialogue_box signals.
|
||||
## Registered with SnapshotEventRouter; reads from GameState directly.
|
||||
|
||||
var dialogue_box: Node = null
|
||||
var monologue_display: Node = null
|
||||
var journal_panel: Node = null
|
||||
|
||||
# Shared mutable reference — GDScript arrays are reference types.
|
||||
# main.gd and this coordinator both append to the same array.
|
||||
var _pending_record_inputs: Array = []
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1
|
||||
|
||||
|
||||
func init(refs: Dictionary, pending_record_inputs: Array) -> DialogueCoordinator:
|
||||
dialogue_box = refs.get("dialogue_box")
|
||||
monologue_display = refs.get("monologue_display")
|
||||
journal_panel = refs.get("journal_panel")
|
||||
_pending_record_inputs = pending_record_inputs
|
||||
return self
|
||||
|
||||
|
||||
## Connect dialogue_box signals. Call from main.gd._ready() after init().
|
||||
func connect_signals() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
dialogue_box.option_selected.connect(on_dialogue_option_selected)
|
||||
dialogue_box.dialogue_dismissed.connect(on_dialogue_dismissed)
|
||||
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)
|
||||
|
||||
|
||||
# -- Snapshot consumers (registered with SnapshotEventRouter) -----------------
|
||||
|
||||
# Consume-once per tick with ID tracking: show dialogue, then clear.
|
||||
func consume_dialogue() -> void:
|
||||
if GameState.current_dialogue == null or not dialogue_box:
|
||||
return
|
||||
if GameState.current_tick == _last_dialogue_tick:
|
||||
return
|
||||
if dialogue_box.is_dialogue_active():
|
||||
GameState.current_dialogue = null
|
||||
return
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
# #264: Close journal when dialogue opens
|
||||
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", "")
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
dlg.get("speech", ""),
|
||||
dlg.get("options", []),
|
||||
_last_dialogue_npc_id
|
||||
)
|
||||
GameState.current_dialogue = null
|
||||
|
||||
|
||||
# #535: Consume overheard NPC-NPC conversation events (D-078).
|
||||
func consume_conversation_events() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_events:
|
||||
dialogue_box.append_conversation_event(event)
|
||||
GameState.conversation_events = []
|
||||
|
||||
|
||||
# #535: Handle conversation_ended events.
|
||||
func consume_conversation_ended() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_ended:
|
||||
dialogue_box.on_conversation_ended(event)
|
||||
GameState.conversation_ended = []
|
||||
|
||||
|
||||
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
||||
func consume_dialogue_response() -> void:
|
||||
if GameState.dialogue_response == null or not dialogue_box:
|
||||
return
|
||||
var dr: Dictionary = GameState.dialogue_response
|
||||
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
|
||||
var speaker_color_index: int = dr.get("speaker_color_index", -1)
|
||||
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
||||
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
# -- Signal handlers ----------------------------------------------------------
|
||||
|
||||
# D-061: Handle dialogue option selection -> send to server
|
||||
func on_dialogue_option_selected(response_id: String, _text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {
|
||||
"target_entity_id": null,
|
||||
"verb": "DialogueResponse",
|
||||
"response_id": response_id,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# D-063: Confrontation beat monologue -> show on monologue display (layer 7)
|
||||
func on_confrontation_monologue(text: String, duration: float) -> void:
|
||||
if not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_confrontation_tick:
|
||||
return
|
||||
_last_confrontation_tick = GameState.current_tick
|
||||
monologue_display.show_monologue(text, duration, 3, true)
|
||||
|
||||
|
||||
# D-061: Auto-pause on dialogue open
|
||||
func on_dialogue_pause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# D-061: Auto-unpause on dialogue close
|
||||
func on_dialogue_unpause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# D-064: Handle walk-away -> send WalkAway{npc_id} to server
|
||||
func on_dialogue_dismissed() -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {
|
||||
"target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
|
||||
"verb": "WalkAway",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator handles dialogue state changes.
|
||||
func on_dialogue_state_changed(active: bool) -> void:
|
||||
GameState.dialogue_active = active
|
||||
|
||||
|
||||
# D-020 (#558): Route audio dip requests.
|
||||
func on_audio_dip_requested(profile: String) -> void:
|
||||
AudioManager.apply_dip(profile)
|
||||
|
||||
|
||||
# D-020 (#558): Route audio dip clear.
|
||||
func on_audio_dip_cleared() -> void:
|
||||
AudioManager.clear_dip()
|
||||
+69
-381
@@ -1,5 +1,15 @@
|
||||
extends Node2D
|
||||
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
|
||||
var _camera_anchored: bool = false
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay
|
||||
var _teleport_in_progress: bool = false # #501/#117: forces camera snap on next frame
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames
|
||||
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||
var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers
|
||||
var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
|
||||
|
||||
@onready var world_renderer = $World
|
||||
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
|
||||
@onready var camera = $Camera2D
|
||||
@@ -24,38 +34,19 @@ extends Node2D
|
||||
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
||||
@onready var star_map = $UILayer/HUD/StarMap # #674: star map insert module (hop-ring view)
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
var _camera_anchored: bool = false
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
||||
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
||||
var _known_triangle_ids: Dictionary = {} # #590: triangle_ids that have already fired the activation chime
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||
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
|
||||
|
||||
func _ready() -> void:
|
||||
print("The Settled Reach — client initialized")
|
||||
|
||||
# #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing.
|
||||
# We lerp camera.global_position directly in _process() using CAMERA_SMOOTHING_SPEED,
|
||||
# matching entity_renderer.gd's exponential smoothing pattern. Built-in smoothing
|
||||
# would conflict because we'd be setting global_position to the target every frame.
|
||||
camera.position_smoothing_enabled = false
|
||||
|
||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||
SimBridge.connect_to_sim()
|
||||
|
||||
# #257: If returning from main menu "Load Game" selection, defer dispatch until connected.
|
||||
# In test mode, connect_to_sim() sets CONNECTED synchronously — dispatch fires immediately.
|
||||
# In live mode, state is CONNECTING — signal handler dispatches once connected.
|
||||
# #257: Deferred load dispatch
|
||||
if not GameState.pending_load_path.is_empty():
|
||||
if loading_screen:
|
||||
loading_screen.show_loading()
|
||||
@@ -65,46 +56,52 @@ func _ready() -> void:
|
||||
SimBridge.connection_state_changed.connect(_on_sim_connected_for_load)
|
||||
|
||||
# Camera anchor: snap to player position before the first frame renders.
|
||||
# In test mode poll_snapshot() returns synchronously — position is set
|
||||
# immediately. In live mode the snapshot isn't available yet — _process
|
||||
# handles it via the lerp block in _process().
|
||||
var first_snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if first_snapshot != null:
|
||||
GameState.apply_snapshot(first_snapshot)
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
|
||||
# D-061: Connect dialogue box signals
|
||||
if dialogue_box:
|
||||
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
|
||||
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
|
||||
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)
|
||||
# #775: Initialize extracted components
|
||||
_consumers = SnapshotConsumers.new().init({
|
||||
"monologue_display": monologue_display,
|
||||
"dialogue_box": dialogue_box,
|
||||
"examine_display": examine_display,
|
||||
"loading_screen": loading_screen,
|
||||
"debug_console": debug_console,
|
||||
"cursor_renderer": cursor_renderer,
|
||||
"interaction_list": interaction_list,
|
||||
"interaction_prompt": interaction_prompt,
|
||||
"minimap": minimap,
|
||||
"star_map": star_map,
|
||||
}, _screen_flash)
|
||||
|
||||
_dialogue = DialogueCoordinator.new().init({
|
||||
"dialogue_box": dialogue_box,
|
||||
"monologue_display": monologue_display,
|
||||
"journal_panel": journal_panel,
|
||||
}, _pending_record_inputs)
|
||||
_dialogue.connect_signals()
|
||||
|
||||
# #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().
|
||||
# #559: Register snapshot dispatch handlers
|
||||
_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)
|
||||
_router.register_always(_consumers.propagate_insert_state)
|
||||
_router.register_always(_consumers.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)
|
||||
_router.register_always(_handle_triangle_crisis_events)
|
||||
_router.register_always(_consumers.play_recognition_chimes)
|
||||
_router.register_always(_consumers.handle_triangle_crisis_events)
|
||||
if gauntlet_hud:
|
||||
_router.register_always(gauntlet_hud.update_from_state)
|
||||
if checklist_overlay:
|
||||
@@ -117,27 +114,34 @@ func _ready() -> void:
|
||||
_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)
|
||||
_router.register_always(_consumers.play_close_sound_events)
|
||||
_router.register_always(_consumers.update_zone)
|
||||
_router.register_always(_consumers.update_listening_focus)
|
||||
_router.register_always(_consumers.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)
|
||||
_router.register("debug_response", _consume_debug_response)
|
||||
_router.register("current_monologue", _consumers.consume_monologue)
|
||||
_router.register("current_dialogue", _dialogue.consume_dialogue)
|
||||
_router.register("conversation_events", _dialogue.consume_conversation_events)
|
||||
_router.register("conversation_ended", _dialogue.consume_conversation_ended)
|
||||
_router.register("dialogue_response", _dialogue.consume_dialogue_response)
|
||||
_router.register("save_result", _consumers.consume_save_result)
|
||||
_router.register("debug_response", _consumers.consume_debug_response)
|
||||
|
||||
# #581: Wire settings_dialog debug console toggle → debug_console.set_enabled
|
||||
# #581: Wire settings_dialog debug console toggle
|
||||
if settings_dialog and debug_console:
|
||||
settings_dialog.debug_console_toggled.connect(debug_console.set_enabled)
|
||||
|
||||
# #581 D-088: Wire debug console pause/unpause — sim must not advance during debug input
|
||||
# #581 D-088: Wire debug console pause/unpause
|
||||
if debug_console:
|
||||
debug_console.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
debug_console.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
debug_console.pause_requested.connect(_dialogue.on_dialogue_pause_requested)
|
||||
debug_console.unpause_requested.connect(_dialogue.on_dialogue_unpause_requested)
|
||||
|
||||
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if event.is_pressed() and not event.is_echo():
|
||||
if event is InputEventKey and event.keycode == KEY_M:
|
||||
if star_map:
|
||||
star_map.toggle_visible()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
@@ -152,20 +156,15 @@ func _process(delta: float) -> void:
|
||||
_teleport_transition()
|
||||
|
||||
# Late anchor: live mode — first snapshot arrives during _process.
|
||||
# Smoothing is already OFF (disabled in _ready), so setting
|
||||
# global_position takes effect immediately with no lerp.
|
||||
if not _camera_anchored:
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
|
||||
# #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.
|
||||
# Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame.
|
||||
# Init: camera already snapped in _ready() or late-anchor path above.
|
||||
# #117: Manual exponential smoothing.
|
||||
if _camera_anchored:
|
||||
var target := GameState.player_position * Constants.TILE_SIZE
|
||||
if _teleport_in_progress:
|
||||
@@ -176,11 +175,9 @@ func _process(delta: float) -> void:
|
||||
camera.global_position = camera.global_position.lerp(target, weight)
|
||||
|
||||
# Send queued input to simulation
|
||||
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
|
||||
# At 60fps/10tps, inputs on non-snapshot frames must not be lost from the ring buffer.
|
||||
var inputs = InputMapper.flush_queue()
|
||||
for input in inputs:
|
||||
# #495: F12 WRONG button — client-only, trigger bug report capture
|
||||
# #495: F12 WRONG button — client-only
|
||||
if input.action == InputMapper.Action.BUG_REPORT:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
@@ -189,7 +186,7 @@ func _process(delta: float) -> void:
|
||||
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||
_toggle_journal()
|
||||
continue
|
||||
# #257: LOAD_GAME — send first, then show loading screen (avoids stuck overlay if send fails)
|
||||
# #257: LOAD_GAME — send first, then show loading screen
|
||||
if input.action == InputMapper.Action.LOAD_GAME:
|
||||
var err := SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
@@ -216,7 +213,6 @@ func _process(delta: float) -> void:
|
||||
if target_id < 0 and interaction_prompt:
|
||||
target_id = interaction_prompt.get_interaction_target()
|
||||
verb = interaction_prompt.get_selected_verb()
|
||||
# Always send struct form for Interact (#415) — server expects named fields
|
||||
if target_id >= 0:
|
||||
input["action_data"] = {
|
||||
"target_entity_id": target_id,
|
||||
@@ -230,9 +226,7 @@ func _process(delta: float) -> void:
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
# #507: Record tick data to ring buffer — once per server tick (snapshot arrival).
|
||||
# Flushes all inputs accumulated since the last snapshot (across multiple display frames),
|
||||
# then clears the accumulator for the next tick.
|
||||
# #507: Record tick data to ring buffer
|
||||
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
|
||||
bug_report_dialog.record_tick(
|
||||
GameState.current_tick,
|
||||
@@ -242,294 +236,13 @@ 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).
|
||||
# Consume-once: events are cleared after processing so they don't replay if
|
||||
# _process runs again before the next server tick (D-009 multiplayer-safe pattern).
|
||||
func _play_close_sound_events() -> void:
|
||||
for evt in GameState.close_sound_events:
|
||||
if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
AudioManager.play_sound_event(
|
||||
evt.get("event_type", ""),
|
||||
Vector2(float(evt.x), float(evt.y))
|
||||
)
|
||||
GameState.close_sound_events = []
|
||||
|
||||
|
||||
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
||||
# enters the cognitive delay recognition queue for the first time.
|
||||
# "The chime marks the character's attention shifting" (D-067).
|
||||
# IDs persist for the session — one chime per entity, no re-trigger on
|
||||
# fog oscillation or server re-send. Cleared on room change (teleport).
|
||||
func _play_recognition_chimes() -> void:
|
||||
for rec in GameState.pending_recognitions:
|
||||
if not rec is Dictionary or not rec.has("entity_id"):
|
||||
continue
|
||||
var eid: int = rec.entity_id
|
||||
if not _known_recognition_ids.has(eid):
|
||||
_known_recognition_ids[eid] = true
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
|
||||
|
||||
# #590 D-072/D-089: Triangle activation consumer — fires sfx_monologue_chime_urgent once
|
||||
# per triangle_id. The tell_state on the activated NPC and subsequent proximity monologue
|
||||
# lines are the visible consequence (D-039 wow moment #2 "The Character's Eye").
|
||||
# No overlay is shown — the chime is the only client-side reaction (D-039 intent).
|
||||
func _handle_triangle_crisis_events() -> void:
|
||||
var events: Array = GameState.current_snapshot.get("triangle_crisis_events", [])
|
||||
for ev in events:
|
||||
if not ev is Dictionary or not ev.has("triangle_id"):
|
||||
continue
|
||||
var tid: int = ev.triangle_id
|
||||
if not _known_triangle_ids.has(tid):
|
||||
_known_triangle_ids[tid] = true
|
||||
AudioManager.play(AudioManager.CHIME_ACTIVATION, AudioManager.BUS_UI_SOUNDS)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||
# (extracted in apply_snapshot(), server-authoritative per D-020).
|
||||
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
|
||||
func _update_zone() -> void:
|
||||
var zone := GameState.current_zone_id
|
||||
if zone != _current_zone:
|
||||
_current_zone = zone
|
||||
AudioManager.set_zone(zone)
|
||||
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
|
||||
# Uses AudioManager.get_active_dip() as single source of truth (no separate flag).
|
||||
# Only activates when no other dip (dialogue/confrontation) is running.
|
||||
# Only deactivates its own dip — never touches dialogue/confrontation.
|
||||
# D-070: no UI indicator — the boost is "felt, not computed."
|
||||
func _update_listening_focus() -> void:
|
||||
var current_dip := AudioManager.get_active_dip()
|
||||
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
|
||||
if threshold_met and current_dip == "":
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
elif not threshold_met and current_dip == "listening_focus":
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# Consume-once per tick: show monologue text, then clear.
|
||||
# Tick guard prevents re-triggering when the same tick is polled multiple
|
||||
# times (client FPS > sim tick rate).
|
||||
func _consume_monologue() -> void:
|
||||
if GameState.current_monologue == null or not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_monologue_tick:
|
||||
return
|
||||
_last_monologue_tick = GameState.current_tick
|
||||
var mono: Dictionary = GameState.current_monologue
|
||||
monologue_display.show_monologue(
|
||||
mono.get("text", ""),
|
||||
mono.get("duration_seconds", 5.0),
|
||||
mono.get("priority", 2),
|
||||
mono.get("is_urgent", false)
|
||||
)
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
_screen_flash(Constants.ENTITY_COLOR_POI, 0.15)
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
|
||||
# Consume-once per tick with ID tracking: show dialogue, then clear.
|
||||
# Tick guard + is_dialogue_active check prevent re-triggering.
|
||||
func _consume_dialogue() -> void:
|
||||
if GameState.current_dialogue == null or not dialogue_box:
|
||||
return
|
||||
if GameState.current_tick == _last_dialogue_tick:
|
||||
return
|
||||
if dialogue_box.is_dialogue_active():
|
||||
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", "")
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
dlg.get("speech", ""),
|
||||
dlg.get("options", []),
|
||||
_last_dialogue_npc_id
|
||||
)
|
||||
GameState.current_dialogue = null
|
||||
|
||||
|
||||
# #535: Consume overheard NPC-NPC conversation events (D-078).
|
||||
# Each event carries pre-occluded text — render verbatim in the dialogue log.
|
||||
func _consume_conversation_events() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_events:
|
||||
dialogue_box.append_conversation_event(event)
|
||||
GameState.conversation_events = []
|
||||
|
||||
|
||||
# #535: Handle conversation_ended events — notify dialogue box to stop tracking pairs.
|
||||
func _consume_conversation_ended() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_ended:
|
||||
dialogue_box.on_conversation_ended(event)
|
||||
GameState.conversation_ended = []
|
||||
|
||||
|
||||
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
||||
# Updates dialogue_box entity display registry with speaker identity from the wire.
|
||||
func _consume_dialogue_response() -> void:
|
||||
if GameState.dialogue_response == null or not dialogue_box:
|
||||
return
|
||||
var dr: Dictionary = GameState.dialogue_response
|
||||
# v0.1: falls back to _last_dialogue_npc_id if wire omits speaker_entity_id.
|
||||
# Edge case: fast re-engagement with a different NPC could misattribute — low probability.
|
||||
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
|
||||
var speaker_color_index: int = dr.get("speaker_color_index", -1)
|
||||
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
||||
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
# #554/#257: Show save/load result notification; hide loading screen on load complete.
|
||||
func _consume_save_result() -> void:
|
||||
if GameState.save_result == null:
|
||||
return
|
||||
var result: Dictionary = GameState.save_result
|
||||
GameState.save_result = null # consume once
|
||||
# #257: Dismiss loading screen regardless of success/failure
|
||||
if loading_screen:
|
||||
loading_screen.hide_loading()
|
||||
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)
|
||||
|
||||
|
||||
# #581: Forward debug_response from server to the debug console.
|
||||
func _consume_debug_response() -> void:
|
||||
if GameState.debug_response == null or not debug_console:
|
||||
return
|
||||
debug_console.append_response(GameState.debug_response)
|
||||
GameState.debug_response = null
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection → send to server
|
||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {
|
||||
"target_entity_id": null,
|
||||
"verb": "DialogueResponse",
|
||||
"response_id": response_id,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# D-063: Handle confrontation beat monologue → show on monologue display (layer 7)
|
||||
# Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour.
|
||||
# Tick guard deduplicates if dialogue box emits the signal multiple times in one tick.
|
||||
func _on_confrontation_monologue(text: String, duration: float) -> void:
|
||||
if not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_confrontation_tick:
|
||||
return
|
||||
_last_confrontation_tick = GameState.current_tick
|
||||
monologue_display.show_monologue(text, duration, 3, true)
|
||||
|
||||
|
||||
# D-061: Auto-pause on dialogue open — routed through input recording (#507, Tyre #3)
|
||||
func _on_dialogue_pause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# D-061: Auto-unpause on dialogue close — routed through input recording (#507, Tyre #3)
|
||||
func _on_dialogue_unpause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# D-064: Handle walk-away → send WalkAway{npc_id} to server
|
||||
func _on_dialogue_dismissed() -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {
|
||||
"target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
|
||||
"verb": "WalkAway",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# 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:
|
||||
func _on_connection_state_changed(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
gauntlet_hud.finalize()
|
||||
|
||||
|
||||
# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED.
|
||||
# pending_load_path is set by main_menu.gd before scene change.
|
||||
func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state != SimBridge.ConnectionState.CONNECTED:
|
||||
return
|
||||
@@ -554,19 +267,13 @@ func _dispatch_pending_load() -> void:
|
||||
loading_screen.hide_loading(false)
|
||||
|
||||
|
||||
# #501: Detect large position jump indicating a teleport (not normal movement).
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
|
||||
# #501: Detect large position jump indicating a teleport.
|
||||
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
|
||||
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
|
||||
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
|
||||
# Clears dialogue/monologue/interaction state (server clears its side too).
|
||||
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
|
||||
func _teleport_transition() -> void:
|
||||
# Set teleport flag — the camera tracking block in _process() will snap
|
||||
# to the player's new position this frame (no lerp). Flag clears after snap.
|
||||
_camera_anchored = true
|
||||
_teleport_in_progress = true
|
||||
|
||||
@@ -574,12 +281,11 @@ func _teleport_transition() -> void:
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
_known_recognition_ids.clear() # D-067: reset chimes for new room
|
||||
_known_triangle_ids.clear() # #590: reset activation chimes for new room
|
||||
_consumers.clear_recognition_state()
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
dialogue_box.hide_dialogue()
|
||||
|
||||
# Fade from black: instant black overlay, fades to transparent over 0.3s
|
||||
# Fade from black
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
_flash_rect = ColorRect.new()
|
||||
@@ -592,35 +298,17 @@ 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.
|
||||
# #264: Toggle journal panel.
|
||||
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.
|
||||
# #502: Full-screen color flash.
|
||||
func _screen_flash(color: Color, duration: float) -> void:
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
|
||||
@@ -93,7 +93,8 @@ func _try_extract_message() -> PackedByteArray:
|
||||
if _pending_length > MAX_MESSAGE_SIZE:
|
||||
# Stream is corrupt — we can't find the next valid frame boundary.
|
||||
# Disconnect rather than silently discarding valid buffered data.
|
||||
push_error("LocalBridge: incoming message too large: %d bytes (max %d) — disconnecting" % [_pending_length, MAX_MESSAGE_SIZE])
|
||||
push_error(
|
||||
"LocalBridge: incoming message too large: %d bytes (max %d) — disconnecting" % [_pending_length, MAX_MESSAGE_SIZE])
|
||||
_corrupt = true
|
||||
_pending_length = -1
|
||||
_read_buffer.clear()
|
||||
|
||||
@@ -35,7 +35,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
# Version check: reject snapshots from incompatible server
|
||||
var version: Variant = raw.get("version")
|
||||
if version != PROTOCOL_VERSION:
|
||||
push_error("Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION])
|
||||
push_error(
|
||||
"Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION])
|
||||
return null
|
||||
|
||||
var entities: Array[Dictionary] = []
|
||||
@@ -49,7 +50,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
dropped += 1
|
||||
|
||||
if dropped > 0:
|
||||
push_error("Protocol: %d/%d entities failed to decode (D-010 information boundary violation)" % [dropped, raw_entities.size()])
|
||||
push_error(
|
||||
"Protocol: %d/%d entities failed to decode (D-010 information boundary violation)" % [dropped, raw_entities.size()])
|
||||
|
||||
# GDScript int is signed 64-bit. Rust tick is u64 but will not exceed 2^63
|
||||
# in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031).
|
||||
@@ -484,21 +486,22 @@ static func _decode_verb_option(raw) -> Variant:
|
||||
static func _decode_enum_variant(raw) -> Dictionary:
|
||||
if raw is String:
|
||||
return { "variant": raw, "data": null }
|
||||
elif raw is Dictionary and raw.size() == 1:
|
||||
if raw is Dictionary and raw.size() == 1:
|
||||
var variant_name: String = raw.keys()[0]
|
||||
return { "variant": variant_name, "data": raw[variant_name] }
|
||||
else:
|
||||
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
|
||||
return { "variant": "Unknown", "data": raw }
|
||||
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
|
||||
return { "variant": "Unknown", "data": raw }
|
||||
|
||||
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588).
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
|
||||
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
|
||||
static func encode_startup_message(world_seed: int, character_archetype: String = "detective") -> PackedByteArray:
|
||||
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
|
||||
static func encode_startup_message(
|
||||
world_seed: int, character_archetype: String = "detective", character_visual: Variant = null) -> PackedByteArray:
|
||||
# Map client lowercase archetype string to server PascalCase enum variant.
|
||||
# Explicit match prevents unknown strings silently reaching the server as
|
||||
# garbage enum values — fail loudly and fall back to "Detective".
|
||||
@@ -515,6 +518,8 @@ static func encode_startup_message(world_seed: int, character_archetype: String
|
||||
"world_seed": world_seed,
|
||||
"character_archetype": archetype_variant,
|
||||
}
|
||||
if character_visual != null and character_visual.has_method("to_dict"):
|
||||
msg["character_visual_descriptor"] = character_visual.to_dict()
|
||||
var result = Messagepack.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: startup message encode failed: %s" % result.status)
|
||||
|
||||
@@ -5,6 +5,20 @@ extends RefCounted
|
||||
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
|
||||
## boundary (no game logic in the production client autoload).
|
||||
|
||||
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),
|
||||
]
|
||||
|
||||
var tick: int = 0
|
||||
var player_pos: Vector2i = Vector2i(10, 10)
|
||||
var facing: String = "North"
|
||||
@@ -116,9 +130,9 @@ func snapshot() -> Dictionary:
|
||||
"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},
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false}, # gdlint:ignore = max-line-length
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false}, # gdlint:ignore = max-line-length
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true}, # gdlint:ignore = max-line-length
|
||||
],
|
||||
}
|
||||
|
||||
@@ -146,7 +160,7 @@ func snapshot() -> Dictionary:
|
||||
{"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": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."}, # gdlint:ignore = max-line-length
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
@@ -259,21 +273,6 @@ func _get_tile_type(x: int, y: int) -> String:
|
||||
|
||||
# -- 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)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ extends Node3D
|
||||
## get_clothing_node_count() — number of clothing MeshInstance3D nodes
|
||||
## get_accessory_node_count() — number of accessory BoneAttachment3D nodes
|
||||
## get_skin_tone_texture_name(index) — skin tone texture filename key for index
|
||||
## get_overhead_anchor() — Marker3D above Head bone for floating UI (#712)
|
||||
##
|
||||
## D-159 (11 body types), D-160 (18 segments), D-161 (head separate),
|
||||
## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork)
|
||||
@@ -101,6 +102,8 @@ var _clothing_meshes: Array[MeshInstance3D] = []
|
||||
var _bone_attachments: Array[BoneAttachment3D] = []
|
||||
var _outline_nodes: Array[MeshInstance3D] = []
|
||||
var _accessory_attachments: Array[BoneAttachment3D] = []
|
||||
var _overhead_anchor: Marker3D = null
|
||||
var _overhead_attachment: BoneAttachment3D = null
|
||||
|
||||
# Inspectable state for tests
|
||||
var _active_torso_variant: String = "full"
|
||||
@@ -218,6 +221,13 @@ func get_skin_tone_texture_name(index: int) -> String:
|
||||
return SKIN_TONES[clampi(index, 0, SKIN_TONES.size() - 1)]["tex"]
|
||||
|
||||
|
||||
## Return the overhead anchor Marker3D (#712). Null if skeleton not loaded.
|
||||
## Anchor point for floating UI elements: status indicators, thought bubbles,
|
||||
## alert markers, speech icons. Positioned ~0.3m above the Head bone.
|
||||
func get_overhead_anchor() -> Marker3D:
|
||||
return _overhead_anchor
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — teardown
|
||||
# =============================================================================
|
||||
@@ -234,6 +244,13 @@ func _clear() -> void:
|
||||
node.free()
|
||||
_outline_nodes.clear()
|
||||
|
||||
# #712: free overhead anchor before general bone attachments
|
||||
if is_instance_valid(_overhead_attachment) and _overhead_attachment.get_parent():
|
||||
_overhead_attachment.get_parent().remove_child(_overhead_attachment)
|
||||
_overhead_attachment.free()
|
||||
_overhead_attachment = null
|
||||
_overhead_anchor = null
|
||||
|
||||
for att in _bone_attachments:
|
||||
if is_instance_valid(att) and att.get_parent():
|
||||
att.get_parent().remove_child(att)
|
||||
@@ -291,6 +308,7 @@ func _load_skeleton() -> void:
|
||||
for m in armature_meshes:
|
||||
print(" armature mesh: ", m.name, " visible=", m.visible)
|
||||
_validate_slot_bones()
|
||||
_create_overhead_anchor()
|
||||
|
||||
|
||||
## Warn on any SLOT_TO_BONE entry that doesn't exist in the loaded skeleton.
|
||||
@@ -302,6 +320,26 @@ func _validate_slot_bones() -> void:
|
||||
push_warning("CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton" % [slot, bone_name])
|
||||
|
||||
|
||||
## #712: Create a Marker3D anchored ~0.3m above the Head bone via BoneAttachment3D.
|
||||
## Anchor point for floating UI elements (status indicators, thought bubbles, etc.).
|
||||
func _create_overhead_anchor() -> void:
|
||||
if _skeleton == null:
|
||||
return
|
||||
var bone_idx := _skeleton.find_bone("Head")
|
||||
if bone_idx == -1:
|
||||
push_warning("CharacterVisual: Head bone not found — overhead anchor not created")
|
||||
return
|
||||
_overhead_attachment = BoneAttachment3D.new()
|
||||
_overhead_attachment.bone_name = "Head"
|
||||
_overhead_attachment.name = "OverheadAttachment"
|
||||
_skeleton.add_child(_overhead_attachment)
|
||||
|
||||
_overhead_anchor = Marker3D.new()
|
||||
_overhead_anchor.name = "OverheadAnchor"
|
||||
_overhead_anchor.position = Vector3(0, 0.3, 0)
|
||||
_overhead_attachment.add_child(_overhead_anchor)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — body segments (D-160)
|
||||
# =============================================================================
|
||||
@@ -413,7 +451,8 @@ func _load_eyebrows(_desc: CharacterVisualDescriptor) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE, render_priority: int = 0) -> BoneAttachment3D:
|
||||
func _attach_to_bone(
|
||||
path: String, bone_name: String, tint: Color = Color.WHITE, _render_priority: int = 0) -> BoneAttachment3D:
|
||||
if _skeleton == null:
|
||||
return null
|
||||
if not ResourceLoader.exists(path):
|
||||
@@ -464,14 +503,13 @@ func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE,
|
||||
_apply_tinted_shader(mi, tint, mask_tex)
|
||||
inst.queue_free()
|
||||
return null
|
||||
else:
|
||||
# Unskinned — rigid attachment via BoneAttachment3D
|
||||
for mi in meshes:
|
||||
mi.get_parent().remove_child(mi)
|
||||
mi.owner = null
|
||||
attachment.add_child(mi)
|
||||
_apply_tinted_shader(mi, tint, mask_tex)
|
||||
inst.queue_free()
|
||||
# Unskinned — rigid attachment via BoneAttachment3D
|
||||
for mi in meshes:
|
||||
mi.get_parent().remove_child(mi)
|
||||
mi.owner = null
|
||||
attachment.add_child(mi)
|
||||
_apply_tinted_shader(mi, tint, mask_tex)
|
||||
inst.queue_free()
|
||||
return attachment
|
||||
|
||||
|
||||
@@ -591,7 +629,8 @@ func _load_accessories(desc: CharacterVisualDescriptor) -> void:
|
||||
# Internal — tinting (hair, accessories, bone-attached assets with tint)
|
||||
# =============================================================================
|
||||
|
||||
func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, render_priority: int = 0) -> void:
|
||||
func _apply_tinted_shader(
|
||||
mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, _render_priority: int = 0) -> void:
|
||||
if mi.mesh == null:
|
||||
return
|
||||
for surf in range(mi.mesh.get_surface_count()):
|
||||
|
||||
@@ -5,19 +5,11 @@ extends Node2D
|
||||
## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer).
|
||||
## Detects entity hover via world-space proximity to visible entities.
|
||||
|
||||
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
|
||||
|
||||
# --- Public ---
|
||||
var current_state: State = State.DEFAULT
|
||||
var hovered_entity_id: int = -1
|
||||
var weapon_mode_active: bool = false
|
||||
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
|
||||
# Cursor shape transitions still fire — the character's body still orients to targets.
|
||||
var insert_active: bool = true
|
||||
|
||||
signal state_changed(new_state: State)
|
||||
signal state_changed(new_state: int)
|
||||
signal hovered_entity_changed(entity_id: int)
|
||||
|
||||
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
|
||||
|
||||
# D-056 colors
|
||||
const COLOR_DEFAULT := Color("#c8d0e0")
|
||||
const COLOR_OBJECT := Color("#8b8ba0")
|
||||
@@ -31,6 +23,14 @@ const HOVER_RADIUS_PX := 16.0 # World pixels — ~half a tile
|
||||
const BRACKET_HALF := 26.0
|
||||
const BRACKET_ARM := 8.0
|
||||
|
||||
# --- Public ---
|
||||
var current_state: State = State.DEFAULT
|
||||
var hovered_entity_id: int = -1
|
||||
var weapon_mode_active: bool = false
|
||||
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
|
||||
# Cursor shape transitions still fire — the character's body still orients to targets.
|
||||
var insert_active: bool = true
|
||||
|
||||
# --- Transition state ---
|
||||
var _target: State = State.DEFAULT
|
||||
var _t: float = 1.0
|
||||
|
||||
@@ -25,6 +25,8 @@ const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for co
|
||||
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
||||
# Fast enough for Sprint snappiness, slow enough for Walk to show sliding.
|
||||
const LERP_SPEED: float = 12.0
|
||||
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
||||
const COLOR_FADE_DURATION: float = 0.5
|
||||
|
||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
||||
@@ -32,9 +34,6 @@ var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last re
|
||||
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
|
||||
|
||||
func _ready() -> void:
|
||||
print("EntityRenderer: Initialized")
|
||||
|
||||
@@ -111,7 +110,8 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
_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])
|
||||
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.
|
||||
|
||||
@@ -5,13 +5,14 @@ extends Node2D
|
||||
## Architecture: docs/architecture/fog-shader-spec.md
|
||||
|
||||
signal fog_noise_ready
|
||||
|
||||
const TILE_SIZE := float(Constants.TILE_SIZE)
|
||||
|
||||
var _noise_ready: bool = false
|
||||
|
||||
var _fog_rect: ColorRect
|
||||
var _shader_mat: ShaderMaterial
|
||||
|
||||
const TILE_SIZE := float(Constants.TILE_SIZE)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Create the fog overlay ColorRect — transparent fallback so a shader failure
|
||||
|
||||
@@ -11,11 +11,11 @@ extends TileMapLayer
|
||||
# (3,0) = object — teal
|
||||
# (4,0) = reset_plate — amber (#502)
|
||||
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
const GROUND_FLOOR: int = 0 # Server floor level for ground — filter target in update_tiles()
|
||||
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
||||
|
||||
# Wire-format string to TileType mapping
|
||||
const TILE_TYPE_MAP: Dictionary = {
|
||||
"floor": TileType.FLOOR,
|
||||
|
||||
@@ -13,13 +13,13 @@ extends Node2D
|
||||
# Overhead (Node2D) — z:300 ceiling/upper structure (placeholder)
|
||||
# FogOverlay (Node2D) — z:900 fog shader (OUTSIDE FogGroup)
|
||||
|
||||
var _last_tick: int = -1
|
||||
|
||||
@onready var tile_renderer = $FogGroup/FloorTiles
|
||||
@onready var fog_renderer = $FogOverlay
|
||||
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
|
||||
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
|
||||
|
||||
var _last_tick: int = -1
|
||||
|
||||
func _ready() -> void:
|
||||
print("WorldRenderer: Initialized (D-049 z-stack)")
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
class_name SnapshotConsumers
|
||||
## Non-dialogue snapshot consumers and event handlers extracted from main.gd (#775).
|
||||
##
|
||||
## Registered with SnapshotEventRouter; reads from GameState directly.
|
||||
## UI node references passed via init(). All methods are zero-argument
|
||||
## callables compatible with SnapshotEventRouter.
|
||||
|
||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost
|
||||
|
||||
var monologue_display: Node = null
|
||||
var dialogue_box: Node = null
|
||||
var examine_display: Node = null
|
||||
var loading_screen: Node = null
|
||||
var debug_console: Node = null
|
||||
var cursor_renderer: Node = null
|
||||
var interaction_list: Node = null
|
||||
var interaction_prompt: Node = null
|
||||
var minimap: Node = null
|
||||
var star_map: Node = null
|
||||
|
||||
var _screen_flash_fn: Callable # Callable(color: Color, duration: float)
|
||||
|
||||
var _last_monologue_tick: int = -1
|
||||
var _known_recognition_ids: Dictionary = {}
|
||||
var _known_triangle_ids: Dictionary = {}
|
||||
var _current_zone: String = ""
|
||||
|
||||
|
||||
func init(refs: Dictionary, screen_flash: Callable) -> SnapshotConsumers:
|
||||
monologue_display = refs.get("monologue_display")
|
||||
dialogue_box = refs.get("dialogue_box")
|
||||
examine_display = refs.get("examine_display")
|
||||
loading_screen = refs.get("loading_screen")
|
||||
debug_console = refs.get("debug_console")
|
||||
cursor_renderer = refs.get("cursor_renderer")
|
||||
interaction_list = refs.get("interaction_list")
|
||||
interaction_prompt = refs.get("interaction_prompt")
|
||||
minimap = refs.get("minimap")
|
||||
star_map = refs.get("star_map")
|
||||
_screen_flash_fn = screen_flash
|
||||
return self
|
||||
|
||||
|
||||
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
|
||||
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)
|
||||
if star_map:
|
||||
star_map.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.
|
||||
func play_close_sound_events() -> void:
|
||||
for evt in GameState.close_sound_events:
|
||||
if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
AudioManager.play_sound_event(
|
||||
evt.get("event_type", ""),
|
||||
Vector2(float(evt.x), float(evt.y))
|
||||
)
|
||||
GameState.close_sound_events = []
|
||||
|
||||
|
||||
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
||||
# enters the cognitive delay recognition queue for the first time.
|
||||
func play_recognition_chimes() -> void:
|
||||
for rec in GameState.pending_recognitions:
|
||||
if not rec is Dictionary or not rec.has("entity_id"):
|
||||
continue
|
||||
var eid: int = rec.entity_id
|
||||
if not _known_recognition_ids.has(eid):
|
||||
_known_recognition_ids[eid] = true
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
|
||||
|
||||
# #590 D-072/D-089: Triangle activation consumer.
|
||||
func handle_triangle_crisis_events() -> void:
|
||||
var events: Array = GameState.current_snapshot.get("triangle_crisis_events", [])
|
||||
for ev in events:
|
||||
if not ev is Dictionary or not ev.has("triangle_id"):
|
||||
continue
|
||||
var tid: int = ev.triangle_id
|
||||
if not _known_triangle_ids.has(tid):
|
||||
_known_triangle_ids[tid] = true
|
||||
AudioManager.play(AudioManager.CHIME_ACTIVATION, AudioManager.BUS_UI_SOUNDS)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade.
|
||||
func update_zone() -> void:
|
||||
var zone := GameState.current_zone_id
|
||||
if zone != _current_zone:
|
||||
_current_zone = zone
|
||||
AudioManager.set_zone(zone)
|
||||
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
|
||||
func update_listening_focus() -> void:
|
||||
var current_dip := AudioManager.get_active_dip()
|
||||
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
|
||||
if threshold_met and current_dip == "":
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
elif not threshold_met and current_dip == "listening_focus":
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# Consume-once per tick: show monologue text, then clear.
|
||||
func consume_monologue() -> void:
|
||||
if GameState.current_monologue == null or not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_monologue_tick:
|
||||
return
|
||||
_last_monologue_tick = GameState.current_tick
|
||||
var mono: Dictionary = GameState.current_monologue
|
||||
monologue_display.show_monologue(
|
||||
mono.get("text", ""),
|
||||
mono.get("duration_seconds", 5.0),
|
||||
mono.get("priority", 2),
|
||||
mono.get("is_urgent", false)
|
||||
)
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
_screen_flash_fn.call(Constants.ENTITY_COLOR_POI, 0.15)
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
# #554/#257: Show save/load result notification; hide loading screen on load complete.
|
||||
func consume_save_result() -> void:
|
||||
if GameState.save_result == null:
|
||||
return
|
||||
var result: Dictionary = GameState.save_result
|
||||
GameState.save_result = null
|
||||
if loading_screen:
|
||||
loading_screen.hide_loading()
|
||||
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)
|
||||
|
||||
|
||||
# #581: Forward debug_response from server to the debug console.
|
||||
func consume_debug_response() -> void:
|
||||
if GameState.debug_response == null or not debug_console:
|
||||
return
|
||||
debug_console.append_response(GameState.debug_response)
|
||||
GameState.debug_response = null
|
||||
|
||||
|
||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||
func consume_examine_result() -> void:
|
||||
if GameState.current_examine_result == null or not examine_display:
|
||||
return
|
||||
var result: Dictionary = GameState.current_examine_result
|
||||
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
|
||||
|
||||
|
||||
## Clear session-scoped recognition state (call on teleport / room change).
|
||||
func clear_recognition_state() -> void:
|
||||
_known_recognition_ids.clear()
|
||||
_known_triangle_ids.clear()
|
||||
@@ -0,0 +1,254 @@
|
||||
class_name SnapshotHandler
|
||||
## Applies ObserverSnapshot data to GameState fields (D-020).
|
||||
##
|
||||
## Extracted from game_state.gd to separate snapshot parsing from state storage.
|
||||
## All methods are static — no instance state required.
|
||||
## Called via GameState.apply_snapshot() which delegates here.
|
||||
|
||||
# DEPRECATED: Client-side stationary_ticks fallback. Remove when server sends
|
||||
# "stationary_ticks" in ObserverSnapshot (D-020 violation).
|
||||
static var _prev_player_position: Vector2 = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
static func apply(snapshot: Dictionary) -> void:
|
||||
GameState.current_snapshot = snapshot
|
||||
|
||||
if snapshot.has("tick"):
|
||||
GameState.current_tick = snapshot.tick
|
||||
|
||||
if snapshot.has("entities"):
|
||||
GameState.visible_entities = snapshot.entities
|
||||
var found_player := false
|
||||
for entity in GameState.visible_entities:
|
||||
if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player":
|
||||
GameState.player_position = Vector2(entity.x, entity.y)
|
||||
if entity.has("entity_id"):
|
||||
GameState.player_entity_id = entity.entity_id
|
||||
found_player = true
|
||||
break
|
||||
if not found_player and GameState.visible_entities.size() > 0:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
GameState.visible_entities.size()])
|
||||
|
||||
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
|
||||
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
|
||||
GameState.stationary_ticks = snapshot.stationary_ticks
|
||||
else:
|
||||
# DEPRECATED fallback — client-side accumulation. Remove when server sends field.
|
||||
if GameState.player_position == _prev_player_position:
|
||||
GameState.stationary_ticks += 1
|
||||
else:
|
||||
GameState.stationary_ticks = 0
|
||||
_prev_player_position = GameState.player_position
|
||||
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends "visible_tiles"
|
||||
if snapshot.has("tiles"):
|
||||
GameState.visible_tiles = snapshot.tiles
|
||||
elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
var has_type := false
|
||||
if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary:
|
||||
has_type = snapshot.visible_tiles[0].has("type")
|
||||
if has_type:
|
||||
GameState.visible_tiles = snapshot.visible_tiles
|
||||
|
||||
if snapshot.has("visible_positions"):
|
||||
GameState.visible_positions.clear()
|
||||
for pos in snapshot.visible_positions:
|
||||
GameState.visible_positions[Vector2i(pos.x, pos.y)] = true
|
||||
|
||||
# v2: game_time (D-031)
|
||||
if snapshot.has("game_time") and snapshot.game_time is Dictionary:
|
||||
GameState.game_time = snapshot.game_time
|
||||
|
||||
# v2: player_facing (D-015)
|
||||
if snapshot.has("player_facing") and snapshot.player_facing is String:
|
||||
GameState.player_facing = snapshot.player_facing
|
||||
|
||||
# v4: nearby_interactions (#404/#405)
|
||||
if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array:
|
||||
GameState.nearby_interactions = snapshot.nearby_interactions
|
||||
else:
|
||||
GameState.nearby_interactions = []
|
||||
|
||||
# v5: current_monologue (#414)
|
||||
if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary:
|
||||
GameState.current_monologue = snapshot.current_monologue
|
||||
else:
|
||||
GameState.current_monologue = null
|
||||
|
||||
# #122: lattice_profile
|
||||
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
|
||||
GameState.lattice_profile = snapshot.lattice_profile
|
||||
|
||||
# v6: player_stance (#449, D-053)
|
||||
if snapshot.has("player_stance") and snapshot.player_stance is String:
|
||||
GameState.player_stance = snapshot.player_stance
|
||||
|
||||
# v6: player_inventory (#449, D-065)
|
||||
if snapshot.has("player_inventory") and snapshot.player_inventory is Array:
|
||||
GameState.player_inventory = snapshot.player_inventory
|
||||
else:
|
||||
GameState.player_inventory = []
|
||||
|
||||
# v7: current_dialogue (#434, D-061)
|
||||
if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary:
|
||||
GameState.current_dialogue = snapshot.current_dialogue
|
||||
else:
|
||||
GameState.current_dialogue = null
|
||||
|
||||
# v7: pending_recognitions (#431, D-059/D-060)
|
||||
if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array:
|
||||
GameState.pending_recognitions = snapshot.pending_recognitions
|
||||
else:
|
||||
GameState.pending_recognitions = []
|
||||
|
||||
# v9: conversation_events (#535, D-078)
|
||||
if snapshot.has("conversation_events") and snapshot.conversation_events is Array:
|
||||
GameState.conversation_events = snapshot.conversation_events
|
||||
else:
|
||||
GameState.conversation_events = []
|
||||
|
||||
# v9: conversation_ended (#535, D-078)
|
||||
if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array:
|
||||
GameState.conversation_ended = snapshot.conversation_ended
|
||||
else:
|
||||
GameState.conversation_ended = []
|
||||
|
||||
# v8: dialogue_response (#305, D-028)
|
||||
if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary:
|
||||
GameState.dialogue_response = snapshot.dialogue_response
|
||||
else:
|
||||
GameState.dialogue_response = null
|
||||
|
||||
# v8: gauntlet mode (#496)
|
||||
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
||||
GameState.gauntlet_mode = true
|
||||
else:
|
||||
GameState.gauntlet_mode = false
|
||||
if snapshot.has("room_id") and snapshot.room_id is String:
|
||||
GameState.room_id = snapshot.room_id
|
||||
else:
|
||||
GameState.room_id = null
|
||||
|
||||
# OQ-07 (#522): insert_active
|
||||
if snapshot.has("insert_active") and snapshot.insert_active is bool:
|
||||
GameState.insert_active = snapshot.insert_active
|
||||
else:
|
||||
GameState.insert_active = true
|
||||
|
||||
# #507: rng_seed
|
||||
if snapshot.has("rng_seed"):
|
||||
GameState.rng_seed = snapshot.rng_seed
|
||||
else:
|
||||
GameState.rng_seed = null
|
||||
|
||||
# D-018: Sound events — partition by range_category.
|
||||
if snapshot.has("sound_events") and snapshot.sound_events is Array:
|
||||
GameState.medium_sound_events = []
|
||||
GameState.close_sound_events = []
|
||||
for se in snapshot.sound_events:
|
||||
if not se is Dictionary:
|
||||
continue
|
||||
var rc: String = se.get("range_category", "")
|
||||
if rc == "Medium":
|
||||
GameState.medium_sound_events.append(se)
|
||||
elif rc == "Close":
|
||||
GameState.close_sound_events.append(se)
|
||||
else:
|
||||
GameState.medium_sound_events = []
|
||||
GameState.close_sound_events = []
|
||||
|
||||
# v10: discovered_pois (#151, D-013)
|
||||
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
|
||||
GameState.discovered_pois = snapshot.discovered_pois
|
||||
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
|
||||
GameState.discovered_pois = snapshot.poi_list
|
||||
|
||||
# v14: examine_result (#174, #242)
|
||||
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
|
||||
GameState.current_examine_result = snapshot.examine_result
|
||||
else:
|
||||
GameState.current_examine_result = null
|
||||
|
||||
# v15: save_result (#554, D-085)
|
||||
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
|
||||
GameState.save_result = snapshot.save_result
|
||||
else:
|
||||
GameState.save_result = null
|
||||
|
||||
# v18: debug_response (#580)
|
||||
if snapshot.has("debug_response") and snapshot.debug_response is Dictionary:
|
||||
GameState.debug_response = snapshot.debug_response
|
||||
else:
|
||||
GameState.debug_response = null
|
||||
|
||||
# v20: settings_response (#627, D-138)
|
||||
if snapshot.has("settings_response") and snapshot.settings_response is Dictionary:
|
||||
GameState.settings_response = snapshot.settings_response
|
||||
var sr: Dictionary = snapshot.settings_response
|
||||
if sr.get("kind") == "full":
|
||||
var sr_settings: Variant = sr.get("settings")
|
||||
if sr_settings is Array:
|
||||
for entry in sr_settings:
|
||||
if not entry is Dictionary:
|
||||
continue
|
||||
if entry.get("key") == "ai_dialogue.enabled":
|
||||
var val: Variant = entry.get("value")
|
||||
if val != null:
|
||||
GameState.ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val)
|
||||
else:
|
||||
GameState.settings_response = null
|
||||
|
||||
# #718: character_visual_descriptor — restored from server snapshot on save/load.
|
||||
if snapshot.has("character_visual_descriptor") and snapshot.character_visual_descriptor is Dictionary:
|
||||
var CVD := load("res://scripts/rendering/character_visual_descriptor.gd")
|
||||
if CVD != null:
|
||||
var restored = CVD.from_dict(snapshot.character_visual_descriptor)
|
||||
if restored != null:
|
||||
GameState.character_visual_descriptor = restored
|
||||
|
||||
# v14: player_knowledge (#264, D-041)
|
||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||
GameState.player_knowledge = snapshot.player_knowledge
|
||||
|
||||
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
|
||||
if snapshot.has("zone_id") and snapshot.zone_id is String:
|
||||
GameState.current_zone_id = snapshot.zone_id
|
||||
else:
|
||||
# DEPRECATED fallback — client-side tile lookup. Remove when server sends top-level "zone_id".
|
||||
var tile_by_coord: Dictionary = {}
|
||||
for vtile in GameState.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(GameState.player_position.x), int(GameState.player_position.y))
|
||||
var player_tile = tile_by_coord.get(player_pos_key, null)
|
||||
GameState.current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
GameState.visibility_sectors.clear()
|
||||
var has_explicit_positions := snapshot.has("visible_positions")
|
||||
if not has_explicit_positions:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
for vtile in snapshot.visible_tiles:
|
||||
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
|
||||
continue
|
||||
var pos := Vector2i(vtile.x, vtile.y)
|
||||
var vis_sector: String = vtile.get("visibility", "")
|
||||
if vtile.has("visibility"):
|
||||
GameState.visibility_sectors[pos] = vis_sector
|
||||
if vis_sector == "BoundaryWall":
|
||||
GameState.boundary_positions[pos] = true
|
||||
elif not has_explicit_positions:
|
||||
GameState.visible_positions[pos] = true
|
||||
|
||||
|
||||
## Extract a bool from a tagged-union {"Bool": true} or plain bool value.
|
||||
static func _extract_bool_setting(key: String, val: Variant) -> bool:
|
||||
if val is bool:
|
||||
return val
|
||||
if val is Dictionary and val.has("Bool"):
|
||||
return bool(val["Bool"])
|
||||
push_warning("GameState: unexpected type for setting '%s': %s" % [key, str(val)])
|
||||
return false
|
||||
@@ -10,7 +10,7 @@
|
||||
## run before the project's class_name registry is fully populated.
|
||||
extends SceneTree
|
||||
|
||||
var _Msgpack: GDScript
|
||||
var _msgpack: GDScript
|
||||
var _count := 0
|
||||
var _errors := 0
|
||||
var _output_dir: String
|
||||
@@ -21,7 +21,7 @@ func _init():
|
||||
|
||||
|
||||
func _run():
|
||||
_Msgpack = load("res://addons/messagepack/messagepack.gd")
|
||||
_msgpack = load("res://addons/messagepack/messagepack.gd")
|
||||
|
||||
# Resolve repo root from Godot project root (client/).
|
||||
# Assumes client/ is one level below repo root — validated below.
|
||||
@@ -81,7 +81,7 @@ func _encode_input(tick: int, action_name: String, action_data: Variant = null)
|
||||
else:
|
||||
action = action_name
|
||||
|
||||
var result = _Msgpack.encode({"tick": tick, "action": action})
|
||||
var result = _msgpack.encode({"tick": tick, "action": action})
|
||||
if result.status != null:
|
||||
push_error("Encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -104,7 +104,7 @@ func _encode_inputs(inputs: Array) -> PackedByteArray:
|
||||
action = action_name
|
||||
wire_inputs.append({"tick": input["tick"], "action": action})
|
||||
|
||||
var result = _Msgpack.encode(wire_inputs)
|
||||
var result = _msgpack.encode(wire_inputs)
|
||||
if result.status != null:
|
||||
push_error("Batch encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
class_name TestAiDialogueSprint26
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const SETTINGS_DIALOG_SCENE = preload("res://ui/settings_dialog.tscn")
|
||||
|
||||
var _original_ai_enabled: bool = true
|
||||
|
||||
@@ -345,7 +346,7 @@ func test_hardware_classify_degradation_ok_at_exact_40_percent() -> void:
|
||||
|
||||
func test_settings_dialog_exposes_ai_dialogue_label_text_method() -> void:
|
||||
# settings_dialog needs a testable API — hardcoded UI strings are easy to drift.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -359,7 +360,7 @@ func test_settings_dialog_exposes_ai_dialogue_label_text_method() -> void:
|
||||
|
||||
func test_settings_dialog_ai_dialogue_label_is_correct() -> void:
|
||||
# D-138: label must be exactly "AI-Enhanced Dialogue" (Jeroen's wording).
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -377,7 +378,7 @@ func test_settings_dialog_ai_dialogue_label_is_correct() -> void:
|
||||
func test_settings_dialog_toggle_disabled_when_hardware_fails() -> void:
|
||||
# D-138 §8: RAM < 1.6 GB → feature disabled, toggle greyed out.
|
||||
# Player receives message but cannot enable the feature.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -396,7 +397,7 @@ func test_settings_dialog_toggle_disabled_when_hardware_fails() -> void:
|
||||
|
||||
func test_settings_dialog_toggle_enabled_when_hardware_passes() -> void:
|
||||
# "pass" → toggle available to interact with.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -415,7 +416,7 @@ func test_settings_dialog_toggle_enabled_when_hardware_passes() -> void:
|
||||
|
||||
func test_settings_dialog_toggle_enabled_when_hardware_marginal() -> void:
|
||||
# D-138 §8: "marginal" → warn but let player proceed. Never force-disable.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -439,8 +440,8 @@ func test_hardware_detector_benchmark_cache_path_is_correct() -> void:
|
||||
var det := _get_detector()
|
||||
if det == null:
|
||||
return
|
||||
assert_str(det.BENCHMARK_CACHE_PATH).override_failure_message(
|
||||
"BENCHMARK_CACHE_PATH must be 'user://ai-dialogue-config.json' (D-138 §8)"
|
||||
assert_str(det.benchmark_cache_path).override_failure_message(
|
||||
"benchmark_cache_path must be 'user://ai-dialogue-config.json' (D-138 §8)"
|
||||
).is_equal("user://ai-dialogue-config.json")
|
||||
|
||||
|
||||
@@ -766,7 +767,7 @@ func test_hardware_detector_battery_suspend_preserves_player_pref_false() -> voi
|
||||
|
||||
|
||||
func test_settings_dialog_inference_suspended_state_defaults_false() -> void:
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -779,7 +780,7 @@ func test_settings_dialog_inference_suspended_state_defaults_false() -> void:
|
||||
|
||||
|
||||
func test_settings_dialog_set_inference_suspended_true() -> void:
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -794,7 +795,7 @@ func test_settings_dialog_set_inference_suspended_true() -> void:
|
||||
|
||||
func test_settings_dialog_resume_clears_suspended_state() -> void:
|
||||
# D-138 §8: resume when plugged in — suspended state clears.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -813,7 +814,7 @@ func test_settings_dialog_toggle_remains_enabled_when_battery_suspended() -> voi
|
||||
# Battery suspend auto-pauses inference but must NOT grey the toggle —
|
||||
# the player can click it to override the suspension.
|
||||
# Only hardware "fail" (RAM < 1.6 GB) may disable the toggle.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -829,7 +830,7 @@ func test_settings_dialog_toggle_remains_enabled_when_battery_suspended() -> voi
|
||||
|
||||
func test_settings_dialog_toggle_enabled_after_resume() -> void:
|
||||
# After resume (plug-in), toggle must be enabled again.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -847,7 +848,7 @@ func test_settings_dialog_toggle_enabled_after_resume() -> void:
|
||||
func test_settings_dialog_toggle_disabled_by_hardware_fail_even_when_suspended() -> void:
|
||||
# Hardware "fail" disables the toggle regardless of battery state.
|
||||
# RAM < 1.6 GB is the only hard disable — battery suspend is not.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -864,7 +865,7 @@ func test_settings_dialog_toggle_disabled_by_hardware_fail_even_when_suspended()
|
||||
func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void:
|
||||
# When inference is battery-suspended, a warning label must be visible
|
||||
# so the player knows why inference isn't running (even though toggle is enabled).
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
@@ -882,7 +883,7 @@ func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void:
|
||||
|
||||
func test_settings_dialog_warning_label_hidden_when_not_suspended() -> void:
|
||||
# Warning label must not show when plugged in — no battery message needed.
|
||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
||||
var scene := SETTINGS_DIALOG_SCENE
|
||||
if scene == null:
|
||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||
return
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
class_name TestAntiTedium
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _instance: Node = null
|
||||
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
@@ -106,7 +107,7 @@ func _make_bug_report_dialog() -> Control:
|
||||
|
||||
func test_bug_report_dialog_exists_in_scene() -> void:
|
||||
# Verify the BugReportDialog node is present and hidden by default.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -125,7 +126,7 @@ func test_bug_report_dialog_exists_in_scene() -> void:
|
||||
func test_bug_report_activates_on_action() -> void:
|
||||
# Inject BUG_REPORT action directly into the queue and verify main.gd
|
||||
# triggers the dialog. This tests the full main._process() handling path.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -170,7 +171,7 @@ func test_bug_report_activates_on_action() -> void:
|
||||
|
||||
func test_no_gauntlet_ui_visible_in_default_mode() -> void:
|
||||
# Load main scene — represents non-Gauntlet (default) play mode
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -224,7 +225,7 @@ func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void:
|
||||
|
||||
func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
|
||||
# Simulate several ticks of normal play — gauntlet UI must never appear.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
@@ -18,13 +18,13 @@ func before_test() -> void:
|
||||
for bus in AudioManager.BUSES:
|
||||
AudioManager.set_volume(bus, 0.0)
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@@ -480,7 +480,7 @@ func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void:
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6},
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
@@ -497,7 +497,7 @@ func test_d067_completion_is_when_entity_absent_from_pending() -> void:
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 1, "total_delay_ticks": 6},
|
||||
"remaining_ticks": 1, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
|
||||
@@ -11,12 +11,11 @@
|
||||
class_name TestBugReportRingBuffer
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
|
||||
# Expected ring buffer capacity per spec.
|
||||
const EXPECTED_CAPACITY := 60
|
||||
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
# Reset GameState fields mutated by tests to prevent cross-test leakage.
|
||||
|
||||
@@ -9,6 +9,7 @@ extends GdUnitTestSuite
|
||||
|
||||
const EXPECTED_PLAYER_POS := Vector2(10, 10)
|
||||
const EXPECTED_CAMERA_POS := Vector2(320, 320) # 10 * 32, 10 * 32
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
@@ -67,7 +68,7 @@ func test_apply_snapshot_sets_player_position() -> void:
|
||||
# --- Camera anchor after _ready() ---
|
||||
|
||||
func test_camera_position_after_ready() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -77,7 +78,7 @@ func test_camera_position_after_ready() -> void:
|
||||
|
||||
|
||||
func test_camera_anchored_flag_after_ready() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -88,7 +89,7 @@ func test_camera_anchored_flag_after_ready() -> void:
|
||||
func test_camera_smoothing_off_after_ready() -> void:
|
||||
# Camera smoothing must be disabled during init to prevent lerp from (0,0).
|
||||
# If this test fails, the camera will visibly drift from origin to player.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -100,7 +101,7 @@ func test_camera_smoothing_off_after_ready() -> void:
|
||||
func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
|
||||
# #117: Manual lerp approach — Godot's built-in smoothing must stay OFF always.
|
||||
# CAMERA_SMOOTHING_SPEED is used as the lerp weight, not Godot's position_smoothing_speed.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -114,7 +115,7 @@ func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
|
||||
# --- Camera behavior across frames ---
|
||||
|
||||
func test_camera_tracks_player_after_process() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -130,7 +131,7 @@ func test_camera_tracks_player_after_process() -> void:
|
||||
func test_camera_lerps_toward_player_movement() -> void:
|
||||
# #117: With manual lerp, camera moves TOWARD player position (not snapping).
|
||||
# After one 16ms frame the camera should be partway between old and new position.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
@@ -161,7 +161,8 @@ func test_reload_descriptor_clears_previous_nodes() -> void:
|
||||
# Second load must produce exactly the same child count — no more, no less.
|
||||
# _clear() frees and rebuilds; any deviation indicates stale nodes accumulating.
|
||||
assert_int(count_after_second).override_failure_message(
|
||||
"load_descriptor must produce identical child count on reload — stale nodes detected if higher, missing cleanup if lower"
|
||||
"load_descriptor must produce identical child count on reload" +
|
||||
" — stale nodes detected if higher, missing cleanup if lower"
|
||||
).is_equal(count_after_first)
|
||||
|
||||
|
||||
@@ -305,7 +306,8 @@ func test_torso_hidden_when_full_coverage_clothing_worn() -> void:
|
||||
desc.clothing_slots = {"torso": "coveralls_basic"}
|
||||
node.load_descriptor(desc)
|
||||
|
||||
var coverage: Dictionary = node.get_active_coverage("coveralls_basic") if node.has_method("get_active_coverage") else {}
|
||||
var coverage: Dictionary = node.get_active_coverage("coveralls_basic") \
|
||||
if node.has_method("get_active_coverage") else {}
|
||||
if coverage.is_empty():
|
||||
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
|
||||
return
|
||||
|
||||
@@ -57,7 +57,8 @@ func test_parse_top_level_quoted_string() -> void:
|
||||
|
||||
|
||||
func test_parse_single_condition() -> void:
|
||||
var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0"
|
||||
var yaml := ("conditions:\n - id: test-1\n description: \"Test condition\"\n" +
|
||||
" condition_type: player_near\n x: 10\n y: 20\n radius: 3.0")
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.has("conditions")).is_true()
|
||||
var conditions: Array = result["conditions"]
|
||||
@@ -70,7 +71,9 @@ func test_parse_single_condition() -> void:
|
||||
|
||||
|
||||
func test_parse_multiple_conditions() -> void:
|
||||
var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East"
|
||||
var yaml := ("conditions:\n - id: cond-a\n condition_type: player_near\n" +
|
||||
" x: 1\n y: 2\n radius: 1.0\n\n" +
|
||||
" - id: cond-b\n condition_type: player_facing\n direction: East")
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
var conditions: Array = result["conditions"]
|
||||
assert_that(conditions.size()).is_equal(2)
|
||||
@@ -80,7 +83,8 @@ func test_parse_multiple_conditions() -> void:
|
||||
|
||||
|
||||
func test_parse_comments_ignored() -> void:
|
||||
var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5"
|
||||
var yaml := ("# This is a comment\nroom_id: test\n# Another comment\n" +
|
||||
"conditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5")
|
||||
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||
assert_that(result.get("room_id")).is_equal("test")
|
||||
var conditions: Array = result["conditions"]
|
||||
|
||||
@@ -352,7 +352,6 @@ func test_fog_overlay_z_layer() -> void:
|
||||
return
|
||||
# This test needs the scene tree to be set up
|
||||
# Verify via scene file inspection rather than runtime
|
||||
pass
|
||||
|
||||
|
||||
# -- Noise animation cycles (D-059) -------------------------------------------
|
||||
|
||||
@@ -19,7 +19,7 @@ func before_each() -> void:
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_inventory = []
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||
GameState.current_zone_id = ""
|
||||
GameState.insert_active = true
|
||||
|
||||
@@ -67,7 +67,8 @@ func test_apply_snapshot_player_facing_missing_keeps_default() -> void:
|
||||
|
||||
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}]},
|
||||
{"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)
|
||||
@@ -83,7 +84,10 @@ func test_apply_snapshot_nearby_interactions_absent_clears_list() -> void:
|
||||
# -- 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}
|
||||
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.")
|
||||
|
||||
@@ -14,7 +14,7 @@ extends GdUnitTestSuite
|
||||
|
||||
func before_each() -> void:
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||
GameState.current_zone_id = ""
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_tiles = []
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
class_name TestHubTeleport
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
|
||||
# -- Fixtures ------------------------------------------------------------------
|
||||
|
||||
@@ -178,8 +180,6 @@ func test_test_mode_teleport_clears_dialogue() -> void:
|
||||
# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0).
|
||||
# These tests verify the distance math against that threshold.
|
||||
|
||||
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
func test_detect_teleport_large_jump() -> void:
|
||||
# Position jump > threshold should be detected as teleport
|
||||
var old_pos := Vector2(10.0, 10.0)
|
||||
|
||||
@@ -44,7 +44,7 @@ func _make_cursor_or_skip() -> Node:
|
||||
|
||||
func _make_interaction_list() -> Node:
|
||||
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
if ResourceLoader.exists(path):
|
||||
var scene = load(path)
|
||||
var node = scene.instantiate()
|
||||
|
||||
@@ -12,7 +12,7 @@ extends GdUnitTestSuite
|
||||
|
||||
func _interaction_list_exists() -> bool:
|
||||
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
if ResourceLoader.exists(path):
|
||||
return true
|
||||
return false
|
||||
@@ -20,7 +20,7 @@ func _interaction_list_exists() -> bool:
|
||||
|
||||
func _make_interaction_list() -> Node:
|
||||
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
"res://scenes/interaction_list.tscn"]:
|
||||
if ResourceLoader.exists(path):
|
||||
var scene = load(path)
|
||||
var node = scene.instantiate()
|
||||
|
||||
@@ -14,7 +14,7 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void:
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player",
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
],
|
||||
"nearby_interactions": [{
|
||||
"entity_id": 2,
|
||||
@@ -93,7 +93,7 @@ func test_protocol_decode_v4_entity_relationship() -> void:
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc",
|
||||
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
|
||||
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
|
||||
],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
## Smoke tests for main scene initialization
|
||||
## Run with: godot4 --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
|
||||
## Run with: godot4 --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd
|
||||
## --ignoreHeadlessMode -a res://tests/
|
||||
class_name TestMainScene
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ extends GdUnitTestSuite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
func _make_minimap() -> Control:
|
||||
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
|
||||
@@ -199,7 +200,7 @@ func test_minimap_in_main_scene_on_insert_overlay() -> void:
|
||||
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()
|
||||
var scene: Node = MAIN_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
@@ -226,7 +227,7 @@ func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
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()
|
||||
var scene: Node = MAIN_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ class_name TestP0Regressions
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
@@ -174,7 +176,7 @@ func test_monologue_carry_forward_preserves_newest() -> void:
|
||||
|
||||
func test_camera_static_during_pause() -> void:
|
||||
# 1. Instantiate main scene — camera anchors at test mode player position
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -204,7 +206,7 @@ func test_camera_static_during_pause() -> void:
|
||||
|
||||
func test_camera_anchored_after_pause_unpause() -> void:
|
||||
# Verify camera stays properly anchored through a pause → unpause cycle.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
@@ -346,7 +346,7 @@ func test_full_v6_snapshot_decode() -> void:
|
||||
],
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player",
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
],
|
||||
"visible_tiles": [
|
||||
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "tile_kind": "Floor"},
|
||||
|
||||
@@ -339,7 +339,7 @@ func test_full_v7_snapshot_decode() -> void:
|
||||
"player_inventory": [{"item_id": 100, "name": "Access Token", "slot": 0}],
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player",
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
],
|
||||
"visible_tiles": [],
|
||||
"nearby_interactions": [],
|
||||
|
||||
@@ -281,7 +281,8 @@ func test_entity_renderer_npc_uses_unknown_teal() -> void:
|
||||
|
||||
func test_entity_renderer_object_uses_grey() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
var obj := [{"entity_id": 3, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Object", "data": null}, "visibility": "Forward"}]
|
||||
var obj := [{"entity_id": 3, "x": 1.0, "y": 1.0, "z": 0,
|
||||
"kind": {"variant": "Object", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(obj)
|
||||
var node = renderer.entity_nodes[3] as Sprite2D
|
||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
class_name TestSessionManagerSprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_MENU_SCENE = preload("res://scenes/main_menu.tscn")
|
||||
|
||||
# Game IDs created during the current test — deleted in after_test().
|
||||
var _created_ids: Array = []
|
||||
|
||||
@@ -166,7 +168,7 @@ 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()
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
assert_that(scene).is_not_null()
|
||||
@@ -175,7 +177,7 @@ func test_main_menu_instantiates_without_crash() -> void:
|
||||
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()
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
||||
@@ -187,7 +189,7 @@ func test_main_menu_has_new_game_button() -> void:
|
||||
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()
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
class_name TestSignalSprint24
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn")
|
||||
|
||||
|
||||
# -- #588: Character archetype field ------------------------------------------
|
||||
|
||||
@@ -204,7 +206,7 @@ func test_protocol_decode_current_ticker_null_when_absent() -> void:
|
||||
|
||||
func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void:
|
||||
# update_from_state() must hide ticker when current_ticker is null.
|
||||
var ticker_scene := load("res://ui/news_ticker.tscn") as PackedScene
|
||||
var ticker_scene := NEWS_TICKER_SCENE
|
||||
assert_that(ticker_scene).is_not_null()
|
||||
var ticker := ticker_scene.instantiate()
|
||||
auto_free(ticker)
|
||||
@@ -224,7 +226,7 @@ func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void:
|
||||
|
||||
func test_news_ticker_visible_when_snapshot_has_ticker() -> void:
|
||||
# update_from_state() must show ticker when current_ticker has text.
|
||||
var ticker_scene := load("res://ui/news_ticker.tscn") as PackedScene
|
||||
var ticker_scene := NEWS_TICKER_SCENE
|
||||
assert_that(ticker_scene).is_not_null()
|
||||
var ticker := ticker_scene.instantiate()
|
||||
auto_free(ticker)
|
||||
@@ -244,7 +246,7 @@ func test_news_ticker_visible_when_snapshot_has_ticker() -> void:
|
||||
|
||||
func test_news_ticker_hides_when_ticker_becomes_null() -> void:
|
||||
# Ticker shown then hidden: update_from_state() with null current_ticker hides it.
|
||||
var ticker_scene := load("res://ui/news_ticker.tscn") as PackedScene
|
||||
var ticker_scene := NEWS_TICKER_SCENE
|
||||
assert_that(ticker_scene).is_not_null()
|
||||
var ticker := ticker_scene.instantiate()
|
||||
auto_free(ticker)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
class_name TestSmoothCameraSprint15
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
@@ -42,7 +44,7 @@ func test_camera_smoothing_speed_constant_reasonable() -> void:
|
||||
|
||||
func test_godot_smoothing_disabled_at_ready() -> void:
|
||||
# #117: Godot's built-in Camera2D smoothing must be OFF (manual lerp replaces it).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -53,7 +55,7 @@ func test_godot_smoothing_disabled_at_ready() -> void:
|
||||
|
||||
func test_godot_smoothing_stays_off_after_frames() -> void:
|
||||
# #117: Smoothing must NOT be re-enabled at any point — manual lerp only.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -70,7 +72,7 @@ func test_godot_smoothing_stays_off_after_frames() -> void:
|
||||
func test_camera_lerps_not_snaps_on_player_move() -> void:
|
||||
# #117: When player moves, camera should lerp (not snap) to new position.
|
||||
# After 1 frame at ~60fps, camera should be partway there — not at target.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -90,7 +92,7 @@ func test_camera_lerps_not_snaps_on_player_move() -> void:
|
||||
func test_camera_converges_to_player_over_multiple_frames() -> void:
|
||||
# #117: After enough frames the camera should be within 1px of target.
|
||||
# At LERP_SPEED=8: ~95% convergence in 0.25s, >99% in 0.5s.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -111,7 +113,7 @@ func test_camera_converges_to_player_over_multiple_frames() -> void:
|
||||
|
||||
func test_camera_stationary_player_no_drift() -> void:
|
||||
# #117: When player is stationary, camera should not drift (lerp to same point).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -133,7 +135,7 @@ func test_camera_stationary_player_no_drift() -> void:
|
||||
func test_teleport_snaps_camera_immediately() -> void:
|
||||
# #117: _teleport_in_progress causes camera to snap (not lerp) in the same frame.
|
||||
# Manually displace camera, set the flag, call _process — camera should snap to target.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -155,7 +157,7 @@ func test_teleport_snaps_camera_immediately() -> void:
|
||||
|
||||
func test_teleport_flag_cleared_after_snap() -> void:
|
||||
# #117: _teleport_in_progress must be false after the snap frame.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -170,7 +172,7 @@ func test_teleport_flag_cleared_after_snap() -> void:
|
||||
func test_camera_resumes_lerp_after_teleport() -> void:
|
||||
# #117: Frame after teleport snap must resume lerp (not continue snapping).
|
||||
# After teleport flag clears, any position delta produces lerp movement.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -200,7 +202,7 @@ func test_camera_resumes_lerp_after_teleport() -> void:
|
||||
|
||||
func test_camera_no_rotation() -> void:
|
||||
# D-015: Camera must be fixed-north in v0.1 — no rotation regardless of facing.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
## Sprint 30 — QA acceptance tests
|
||||
##
|
||||
## Covers all 5 Sprint 30 client tickets:
|
||||
## #718 — Persist CharacterVisualDescriptor on new game start
|
||||
## #719 — Hair highlight: make swatch read-only (Option B — no compositor yet)
|
||||
## #720 — Replace DirAccess scanning with manifest JSON for export builds
|
||||
## #712 — BoneAttachment3D marker above Head bone for floating icons
|
||||
## #674 — Star map insert module (test-first: scene must exist when implemented)
|
||||
##
|
||||
## Test convention:
|
||||
## - Tests that should PASS immediately = regression guards on existing code
|
||||
## - Tests prefixed [ACCEPTANCE] = will FAIL until the ticket is implemented
|
||||
##
|
||||
## Ticket refs: #718, #719, #720, #712, #674
|
||||
class_name TestSprint30
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# Reset GameState fields touched by #718 tests to avoid cross-test pollution.
|
||||
GameState.character_visual_descriptor = null
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
GameState.character_visual_descriptor = null
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #718 — Persist CharacterVisualDescriptor
|
||||
# =============================================================================
|
||||
|
||||
func test_game_state_has_character_visual_descriptor_field() -> void:
|
||||
## GameState.character_visual_descriptor must exist and default to null.
|
||||
## Confirms the field added in game_state.gd line 103 is present.
|
||||
var gs := GameState.new()
|
||||
auto_free(gs)
|
||||
# The field is declared on the class — access it without error
|
||||
var val: Variant = gs.get("character_visual_descriptor")
|
||||
# Field should exist (not return null from missing property vs. null value)
|
||||
assert_bool(gs.has_method("apply_snapshot")).override_failure_message(
|
||||
"GameState must be a valid autoload class with apply_snapshot"
|
||||
).is_true()
|
||||
# The property itself must be gettable and null by default
|
||||
assert_bool(val == null).override_failure_message(
|
||||
"GameState.character_visual_descriptor must default to null"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_to_dict_includes_all_required_fields() -> void:
|
||||
## CharacterVisualDescriptor.to_dict() must include all wire-format fields.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
var d := desc.to_dict()
|
||||
var required_keys := [
|
||||
"body_type", "head_id", "hair_id", "hair_tint",
|
||||
"facial_hair_id", "facial_hair_tint", "eyebrow_id", "eyebrow_tint",
|
||||
"eye_color", "skin_tone", "clothing_slots", "clothing_tints",
|
||||
"accessory_slots", "accessory_tints",
|
||||
]
|
||||
for key in required_keys:
|
||||
assert_bool(d.has(key)).override_failure_message(
|
||||
"to_dict() must include field '%s'" % key
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_to_dict_body_type_is_wire_string() -> void:
|
||||
## body_type in to_dict() must be a string (rmp_serde unit enum), not an int.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||||
var d := desc.to_dict()
|
||||
assert_str(d["body_type"]).override_failure_message(
|
||||
"to_dict() body_type must be wire string 'AverageM'"
|
||||
).is_equal("AverageM")
|
||||
|
||||
|
||||
func test_descriptor_round_trip_preserves_fields() -> void:
|
||||
## from_dict(to_dict(desc)) must preserve all scalar fields.
|
||||
var original := CharacterVisualDescriptor.new()
|
||||
auto_free(original)
|
||||
original.body_type = CharacterVisualDescriptor.BodyType.THIN_F
|
||||
original.head_id = "head_002"
|
||||
original.hair_id = "bob"
|
||||
original.hair_tint = Color(0.8, 0.4, 0.2)
|
||||
original.skin_tone = 3
|
||||
|
||||
var wire := original.to_dict()
|
||||
var restored := CharacterVisualDescriptor.from_dict(wire)
|
||||
assert_bool(restored != null).override_failure_message(
|
||||
"from_dict() must return a descriptor for valid wire data"
|
||||
).is_true()
|
||||
if restored == null:
|
||||
return
|
||||
|
||||
assert_int(int(restored.body_type)).override_failure_message(
|
||||
"body_type must survive round-trip"
|
||||
).is_equal(int(CharacterVisualDescriptor.BodyType.THIN_F))
|
||||
|
||||
assert_str(restored.head_id).override_failure_message(
|
||||
"head_id must survive round-trip"
|
||||
).is_equal("head_002")
|
||||
|
||||
assert_str(restored.hair_id).override_failure_message(
|
||||
"hair_id must survive round-trip"
|
||||
).is_equal("bob")
|
||||
|
||||
assert_int(restored.skin_tone).override_failure_message(
|
||||
"skin_tone must survive round-trip"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
func test_descriptor_from_dict_returns_null_when_missing_body_type() -> void:
|
||||
## from_dict() must return null if body_type is absent (required field).
|
||||
var d := {"head_id": "head_001"} # missing body_type
|
||||
var result := CharacterVisualDescriptor.from_dict(d)
|
||||
assert_bool(result == null).override_failure_message(
|
||||
"from_dict() must return null when body_type is missing"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_color_encoding_is_float_array() -> void:
|
||||
## Colors must encode as [r, g, b, a] float arrays for rmp_serde compatibility.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
desc.eye_color = Color(0.1, 0.2, 0.3, 1.0)
|
||||
var d := desc.to_dict()
|
||||
var encoded: Variant = d["eye_color"]
|
||||
assert_bool(encoded is Array).override_failure_message(
|
||||
"eye_color must encode as an Array [r, g, b, a]"
|
||||
).is_true()
|
||||
if not (encoded is Array):
|
||||
return
|
||||
assert_int((encoded as Array).size()).override_failure_message(
|
||||
"eye_color array must have 4 elements"
|
||||
).is_equal(4)
|
||||
assert_float((encoded as Array)[0]).override_failure_message(
|
||||
"eye_color[0] (r) must be approx 0.1"
|
||||
).is_equal_approx(0.1, 0.001)
|
||||
|
||||
|
||||
func test_apply_snapshot_restores_character_visual_descriptor() -> void:
|
||||
## #718: apply_snapshot() must restore character_visual_descriptor from
|
||||
## the "character_visual_descriptor" key in ObserverSnapshot (save/load path).
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F
|
||||
desc.head_id = "head_003"
|
||||
desc.hair_id = "dreads"
|
||||
desc.skin_tone = 5
|
||||
var snapshot := {
|
||||
"character_visual_descriptor": desc.to_dict(),
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
var restored: Variant = GameState.character_visual_descriptor
|
||||
assert_bool(restored != null).override_failure_message(
|
||||
"apply_snapshot() must restore character_visual_descriptor from snapshot"
|
||||
).is_true()
|
||||
if restored != null and restored is CharacterVisualDescriptor:
|
||||
var r := restored as CharacterVisualDescriptor
|
||||
assert_int(int(r.body_type)).override_failure_message(
|
||||
"restored body_type must match"
|
||||
).is_equal(int(CharacterVisualDescriptor.BodyType.MUSCULAR_F))
|
||||
assert_str(r.head_id).override_failure_message(
|
||||
"restored head_id must match"
|
||||
).is_equal("head_003")
|
||||
assert_int(r.skin_tone).override_failure_message(
|
||||
"restored skin_tone must match"
|
||||
).is_equal(5)
|
||||
|
||||
|
||||
func test_apply_snapshot_preserves_descriptor_when_field_absent() -> void:
|
||||
## #718: If snapshot lacks "character_visual_descriptor", the existing field
|
||||
## must not be overwritten (server only sends when descriptor changes).
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.hair_id = "bob"
|
||||
GameState.character_visual_descriptor = desc
|
||||
# Snapshot with no character_visual_descriptor key
|
||||
GameState.apply_snapshot({"tick": 1})
|
||||
var after: Variant = GameState.character_visual_descriptor
|
||||
assert_bool(after != null).override_failure_message(
|
||||
"apply_snapshot() must NOT clear descriptor when field is absent"
|
||||
).is_true()
|
||||
if after is CharacterVisualDescriptor:
|
||||
assert_str((after as CharacterVisualDescriptor).hair_id).override_failure_message(
|
||||
"descriptor must be unchanged after snapshot with no character_visual_descriptor key"
|
||||
).is_equal("bob")
|
||||
|
||||
|
||||
func test_protocol_encode_startup_includes_descriptor() -> void:
|
||||
## #718: Protocol.encode_startup_message() must include "character_visual_descriptor"
|
||||
## in the encoded payload when a descriptor is provided.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.THIN_M
|
||||
desc.hair_id = "buzzed"
|
||||
var bytes := Protocol.encode_startup_message(12345, "detective", desc)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"encode_startup_message() must produce non-empty bytes"
|
||||
).is_true()
|
||||
# Decode and verify the field is present (Messagepack.decode returns {status, value})
|
||||
var raw = Messagepack.decode(bytes)
|
||||
assert_bool(raw.status == null).override_failure_message(
|
||||
"encode_startup_message() output must be valid msgpack"
|
||||
).is_true()
|
||||
if raw.status != null:
|
||||
return
|
||||
var msg: Dictionary = raw.value as Dictionary
|
||||
assert_bool(msg.has("character_visual_descriptor")).override_failure_message(
|
||||
"StartupMessage must include 'character_visual_descriptor' key when descriptor is provided"
|
||||
).is_true()
|
||||
if msg.has("character_visual_descriptor"):
|
||||
assert_bool(msg["character_visual_descriptor"] is Dictionary).override_failure_message(
|
||||
"character_visual_descriptor in StartupMessage must be a Dictionary"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_protocol_encode_startup_omits_descriptor_when_null() -> void:
|
||||
## #718: encode_startup_message() must still produce valid bytes when descriptor is null.
|
||||
var bytes := Protocol.encode_startup_message(0, "detective", null)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"encode_startup_message() must produce valid bytes even with null descriptor"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_has_no_hair_highlight_tint_field() -> void:
|
||||
## CharacterVisualDescriptor must NOT have a hair_highlight_tint field.
|
||||
## The highlight is always auto-derived from hair_tint (Option B of #719).
|
||||
## to_dict() must not include it in the wire format.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
var d := desc.to_dict()
|
||||
assert_bool(d.has("hair_highlight_tint")).override_failure_message(
|
||||
"to_dict() must NOT include hair_highlight_tint — highlight is auto-derived"
|
||||
).is_false()
|
||||
assert_bool("hair_highlight_tint" in desc).override_failure_message(
|
||||
"CharacterVisualDescriptor must not define a hair_highlight_tint property"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #719 — Hair highlight swatch (Option B: read-only, auto-derived)
|
||||
# =============================================================================
|
||||
|
||||
func test_derive_hair_highlight_lightens_primary() -> void:
|
||||
## _derive_hair_highlight() must return primary.lightened(0.3).
|
||||
## Tests the derivation formula in character_creation.gd:1917.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_derive_hair_highlight_lightens_primary: scene not available in headless — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
push_warning("test_derive_hair_highlight_lightens_primary: failed to instantiate — skipping")
|
||||
return
|
||||
auto_free(cc)
|
||||
|
||||
# CharacterCreation._derive_hair_highlight is a private method but testable via call()
|
||||
var primary := Color(0.4, 0.3, 0.5)
|
||||
var expected := primary.lightened(0.3)
|
||||
var result: Variant = cc.call("_derive_hair_highlight", primary)
|
||||
assert_bool(result is Color).override_failure_message(
|
||||
"_derive_hair_highlight must return a Color"
|
||||
).is_true()
|
||||
if not (result is Color):
|
||||
return
|
||||
var r := result as Color
|
||||
assert_float(r.r).override_failure_message("derived highlight.r incorrect").is_equal_approx(expected.r, 0.001)
|
||||
assert_float(r.g).override_failure_message("derived highlight.g incorrect").is_equal_approx(expected.g, 0.001)
|
||||
assert_float(r.b).override_failure_message("derived highlight.b incorrect").is_equal_approx(expected.b, 0.001)
|
||||
|
||||
|
||||
func test_hair_highlight_swatch_exists_in_ui() -> void:
|
||||
## [ACCEPTANCE #719] After fix, _hair_highlight_swatch must be non-null
|
||||
## (a display node must be created in _build_hair_color_dock).
|
||||
## WILL FAIL until #719 is implemented.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_hair_highlight_swatch_exists_in_ui: scene not available in headless — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
add_child(cc)
|
||||
await get_tree().process_frame
|
||||
|
||||
# _hair_highlight_swatch must be set after _ready() builds the hair color dock
|
||||
var swatch: Variant = cc.get("_hair_highlight_swatch")
|
||||
assert_bool(swatch != null).override_failure_message(
|
||||
"[#719] _hair_highlight_swatch must not be null — a display node must be created"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_hair_highlight_swatch_is_not_interactive() -> void:
|
||||
## [ACCEPTANCE #719] The highlight swatch must be non-interactive.
|
||||
## Either mouse_filter = IGNORE, or the node is a ColorRect (not a Button with a callback).
|
||||
## WILL FAIL until #719 is implemented.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_hair_highlight_swatch_is_not_interactive: scene not available — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
add_child(cc)
|
||||
await get_tree().process_frame
|
||||
|
||||
var swatch: Variant = cc.get("_hair_highlight_swatch")
|
||||
if swatch == null:
|
||||
push_warning("test_hair_highlight_swatch_is_not_interactive: swatch not found — #719 not yet implemented")
|
||||
return
|
||||
|
||||
# If swatch is a Control node, mouse_filter must be IGNORE (2)
|
||||
if swatch is Control:
|
||||
var ctrl := swatch as Control
|
||||
assert_int(ctrl.mouse_filter).override_failure_message(
|
||||
"[#719] hair highlight swatch must have mouse_filter=IGNORE (non-interactive)"
|
||||
).is_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #720 — Manifest JSON completeness (replaces DirAccess scanning)
|
||||
# =============================================================================
|
||||
|
||||
func test_manifest_json_is_parseable() -> void:
|
||||
## manifest.json must exist and parse as a Dictionary.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
assert_bool(ResourceLoader.exists(path) or FileAccess.file_exists(path)).override_failure_message(
|
||||
"manifest.json must exist at res://assets/characters/manifest.json"
|
||||
).is_true()
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_json_is_parseable: file not openable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
assert_bool(parsed is Dictionary).override_failure_message(
|
||||
"manifest.json must parse as a JSON object (Dictionary)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_hair_includes_all_asset_dirs() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "hair" array must include every .glb in
|
||||
## assets/characters/hair/. Currently missing: balding, buzzed_female, dreads,
|
||||
## long_dreads, mohawk, ponytail_f, simple_parted, slick_back.
|
||||
## WILL FAIL until #720 populates the manifest fully.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_hair_includes_all_asset_dirs: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var hair_list: Array = manifest.get("hair", [])
|
||||
|
||||
# All hair IDs confirmed from assets/characters/hair/*.glb scan (2026-04-04)
|
||||
var expected_hair := [
|
||||
"bald", "balding", "bob", "buns", "buzzed", "buzzed_female",
|
||||
"dreads", "long", "long_dreads", "mohawk", "ponytail", "ponytail_f",
|
||||
"simple_parted", "slick_back",
|
||||
]
|
||||
for hair_id in expected_hair:
|
||||
assert_bool(hair_list.has(hair_id)).override_failure_message(
|
||||
"[#720] manifest 'hair' must include '%s'" % hair_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_heads_are_populated() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "heads" must not be empty.
|
||||
## heads/templates/ contains head_001..head_004 — all must be listed.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_heads_are_populated: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var heads_list: Array = manifest.get("heads", [])
|
||||
|
||||
var expected_heads := ["head_001", "head_002", "head_003", "head_004"]
|
||||
assert_bool(not heads_list.is_empty()).override_failure_message(
|
||||
"[#720] manifest 'heads' must not be empty — 4 head templates exist"
|
||||
).is_true()
|
||||
for head_id in expected_heads:
|
||||
assert_bool(heads_list.has(head_id)).override_failure_message(
|
||||
"[#720] manifest 'heads' must include '%s'" % head_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_body_types_includes_all_11() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "body_types" must include all 11 types.
|
||||
## Currently has 6; missing: thin_m, thin_f, heavy_m, heavy_f, child.
|
||||
## WILL FAIL until #720 updates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_body_types_includes_all_11: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var bt_list: Array = manifest.get("body_types", [])
|
||||
|
||||
var expected_types := [
|
||||
"thin_m", "thin_f", "average_m", "average_f",
|
||||
"muscular_m", "muscular_f", "teen_m", "teen_f",
|
||||
"heavy_m", "heavy_f", "child",
|
||||
]
|
||||
for bt in expected_types:
|
||||
assert_bool(bt_list.has(bt)).override_failure_message(
|
||||
"[#720] manifest 'body_types' must include '%s'" % bt
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_clothing_includes_all_items() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "clothing" must include all items in
|
||||
## assets/characters/clothing/. Currently missing: boots_work, coveralls_basic,
|
||||
## jacket_utility, pants_cargo, shirt_henley.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_clothing_includes_all_items: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var clothing_data: Variant = manifest.get("clothing", {})
|
||||
var clothing_keys: Array = []
|
||||
if clothing_data is Dictionary:
|
||||
clothing_keys = (clothing_data as Dictionary).keys()
|
||||
|
||||
# All clothing item IDs confirmed from assets/characters/clothing/ scan (2026-04-04)
|
||||
var expected_items := [
|
||||
"boots_work", "coveralls_basic", "jacket_utility", "pants_cargo",
|
||||
"peasant_pants", "peasant_shoes", "peasant_tunic", "shirt_henley",
|
||||
]
|
||||
for item_id in expected_items:
|
||||
assert_bool(clothing_keys.has(item_id)).override_failure_message(
|
||||
"[#720] manifest 'clothing' must include '%s'" % item_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_eyebrows_are_populated() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "eyebrows" must list all eyebrow styles.
|
||||
## assets/characters/eyebrows/ has: female, regular, teen, thick.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_eyebrows_are_populated: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var eb_list: Array = manifest.get("eyebrows", [])
|
||||
|
||||
var expected_eyebrows := ["female", "regular", "teen", "thick"]
|
||||
assert_bool(not eb_list.is_empty()).override_failure_message(
|
||||
"[#720] manifest 'eyebrows' must not be empty"
|
||||
).is_true()
|
||||
for eb_id in expected_eyebrows:
|
||||
assert_bool(eb_list.has(eb_id)).override_failure_message(
|
||||
"[#720] manifest 'eyebrows' must include '%s'" % eb_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_dir_access_scan_functions_removed() -> void:
|
||||
## [ACCEPTANCE #720] After fix, _scan_subdirs and _scan_asset_ids must be
|
||||
## removed from CharacterCreation. These functions fail in exported PCK builds.
|
||||
## WILL FAIL until #720 removes the DirAccess fallbacks.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_dir_access_scan_functions_removed: scene not available — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
|
||||
assert_bool(cc.has_method("_scan_subdirs")).override_failure_message(
|
||||
"[#720] _scan_subdirs must be removed — use manifest JSON instead"
|
||||
).is_false()
|
||||
assert_bool(cc.has_method("_scan_asset_ids")).override_failure_message(
|
||||
"[#720] _scan_asset_ids must be removed — use manifest JSON instead"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #712 — BoneAttachment3D overhead anchor in CharacterVisual
|
||||
# =============================================================================
|
||||
|
||||
func test_character_visual_has_get_overhead_anchor() -> void:
|
||||
## CharacterVisual must expose get_overhead_anchor() as part of its public API.
|
||||
## This is a static assertion — no 3D assets required.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
assert_bool(cv.has_method("get_overhead_anchor")).override_failure_message(
|
||||
"CharacterVisual must have get_overhead_anchor() method (#712)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_overhead_anchor_is_null_before_load() -> void:
|
||||
## get_overhead_anchor() must return null before load_descriptor() is called.
|
||||
## The anchor is created during skeleton load, not at construction.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
var anchor: Variant = cv.get_overhead_anchor()
|
||||
assert_bool(anchor == null).override_failure_message(
|
||||
"get_overhead_anchor() must be null before load_descriptor() is called"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_overhead_anchor_offset_constant() -> void:
|
||||
## [ACCEPTANCE #712] If CharacterVisual exposes the overhead anchor offset
|
||||
## as a constant or via get_overhead_anchor(), the offset must be Vector3(0, 0.3, 0).
|
||||
## Verified via code inspection: _overhead_anchor.position = Vector3(0, 0.3, 0).
|
||||
## This test loads a scene and verifies if assets are present.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
add_child(cv)
|
||||
|
||||
# Without GLB assets available in headless, skeleton load is a no-op.
|
||||
# Check that _overhead_attachment is also null before load (belt-and-suspenders).
|
||||
var attachment: Variant = cv.get("_overhead_attachment")
|
||||
assert_bool(attachment == null).override_failure_message(
|
||||
"_overhead_attachment must be null before skeleton is loaded"
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #674 — Star map insert module (test-first)
|
||||
# =============================================================================
|
||||
|
||||
func test_star_map_scene_exists() -> void:
|
||||
## [ACCEPTANCE #674] The star map scene must exist at the expected path.
|
||||
## WILL FAIL until #674 is implemented.
|
||||
var expected_path := "res://ui/star_map.tscn"
|
||||
assert_bool(ResourceLoader.exists(expected_path)).override_failure_message(
|
||||
"[#674] Star map scene must exist at res://ui/star_map.tscn"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_star_map_is_accessible_from_insert_ui() -> void:
|
||||
## [ACCEPTANCE #674] The star map module must be reachable from the insert UI.
|
||||
## Verify via HUD or main scene that a star_map node/scene is connected.
|
||||
## WILL FAIL until #674 wires the scene into the insert layer.
|
||||
var hud_scene_path := "res://ui/hud.tscn"
|
||||
if not ResourceLoader.exists(hud_scene_path):
|
||||
push_warning("test_star_map_is_accessible_from_insert_ui: HUD scene not found — skipping")
|
||||
return
|
||||
var packed := load(hud_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var hud := packed.instantiate()
|
||||
if hud == null:
|
||||
return
|
||||
auto_free(hud)
|
||||
add_child(hud)
|
||||
await get_tree().process_frame
|
||||
|
||||
# Star map must be reachable as a named node from the HUD or insert layer
|
||||
var star_map := hud.get_node_or_null("StarMap")
|
||||
assert_bool(star_map != null).override_failure_message(
|
||||
"[#674] HUD must contain a StarMap node accessible from the insert UI"
|
||||
).is_true()
|
||||
@@ -17,6 +17,8 @@
|
||||
class_name TestTimeDisplaySprint17
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _clock: Control = null
|
||||
|
||||
|
||||
@@ -275,7 +277,7 @@ func test_sim_bridge_day_phase_is_valid() -> void:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
func test_insert_clock_exists_in_ui_layer() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
@@ -283,7 +285,7 @@ func test_insert_clock_exists_in_ui_layer() -> void:
|
||||
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 scene := MAIN_SCENE
|
||||
var instance = scene.instantiate()
|
||||
auto_free(instance)
|
||||
add_child(instance)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
class_name TestUIFrameworkSprint15
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
@@ -41,7 +43,7 @@ func test_dialogue_max_width_set() -> void:
|
||||
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
# D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10.
|
||||
# Constants.CANVAS_INSERT must match.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -53,7 +55,7 @@ func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
|
||||
func test_ui_layer_is_canvas_layer_20() -> void:
|
||||
# D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -65,7 +67,7 @@ func test_ui_layer_is_canvas_layer_20() -> void:
|
||||
|
||||
func test_modal_layer_is_canvas_layer_30() -> void:
|
||||
# D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -91,7 +93,7 @@ func test_modal_layer_above_ui_layer() -> void:
|
||||
|
||||
func test_monologue_display_exists_in_ui_layer() -> void:
|
||||
# D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -101,7 +103,7 @@ func test_monologue_display_exists_in_ui_layer() -> void:
|
||||
|
||||
func test_stance_indicator_exists_in_ui_layer() -> void:
|
||||
# D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -112,7 +114,7 @@ func test_stance_indicator_exists_in_ui_layer() -> void:
|
||||
func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||
# 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")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -122,7 +124,7 @@ func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||
|
||||
func test_hud_exists_in_ui_layer() -> void:
|
||||
# D-049: HUD must be in UILayer.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -132,7 +134,7 @@ func test_hud_exists_in_ui_layer() -> void:
|
||||
|
||||
func test_interaction_list_exists_in_insert_overlay() -> void:
|
||||
# D-057: InteractionList must be in InsertOverlay (z-layer 6).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -142,7 +144,7 @@ func test_interaction_list_exists_in_insert_overlay() -> void:
|
||||
|
||||
func test_dialogue_box_exists_in_insert_overlay() -> void:
|
||||
# D-061: DialogueBox must be in InsertOverlay (z-layer 6).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -152,7 +154,7 @@ func test_dialogue_box_exists_in_insert_overlay() -> void:
|
||||
|
||||
func test_world_radial_exists_in_insert_overlay() -> void:
|
||||
# D-058: WorldRadial must be in InsertOverlay (z-layer 6).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -162,7 +164,7 @@ func test_world_radial_exists_in_insert_overlay() -> void:
|
||||
|
||||
func test_cursor_renderer_exists_in_ui_layer() -> void:
|
||||
# D-056: CursorRenderer must be in UILayer (topmost, z-layer 7).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -182,7 +184,7 @@ func test_gamestate_insert_active_defaults_true() -> void:
|
||||
func test_insert_active_propagates_on_process() -> void:
|
||||
# OQ-07 (#522): After apply_snapshot with insert_active=false,
|
||||
# the next _process() call must propagate the state to z-layer-6 nodes.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -235,7 +237,7 @@ func test_follow_target_id_is_negative_one_by_default() -> void:
|
||||
func test_monologue_display_receives_first_tick_monologue() -> void:
|
||||
# #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot.
|
||||
# Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -254,7 +256,7 @@ func test_monologue_display_receives_first_tick_monologue() -> void:
|
||||
|
||||
func test_fog_group_exists_in_world() -> void:
|
||||
# Sprint 14 regression: fog rendering must still be present after sprint 15 changes.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -264,7 +266,7 @@ func test_fog_group_exists_in_world() -> void:
|
||||
|
||||
func test_floor_tiles_in_fog_group() -> void:
|
||||
# Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -274,7 +276,7 @@ func test_floor_tiles_in_fog_group() -> void:
|
||||
|
||||
func test_entities_in_ysort_group() -> void:
|
||||
# Sprint 14 regression: Entities must be in YSortGroup for y-sort ordering (D-049 z:100).
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
@@ -288,7 +290,7 @@ func test_entities_in_ysort_group() -> void:
|
||||
|
||||
func test_tile_renderer_skips_nonzero_z() -> void:
|
||||
# #71: Tiles with z != 0 must be filtered out by update_tiles().
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
var scene := MAIN_SCENE
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
@@ -250,7 +250,8 @@ func test_dialogue_theme_format() -> void:
|
||||
|
||||
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 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]
|
||||
|
||||
@@ -31,7 +31,7 @@ func _init():
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run():
|
||||
func _run(): # gdlint:disable=max-returns
|
||||
# Parse CLI args (after -- separator)
|
||||
_parse_args()
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ func apply_setup(scenario_name: String, tree_root: Node) -> bool:
|
||||
sim_bridge.harness.player_pos = Vector2i(10, 10)
|
||||
# Zone tint is written from visible_tiles with zone_id field.
|
||||
# We patch GameState.visible_tiles after the first snapshot in post_setup().
|
||||
pass
|
||||
|
||||
"fog_debug":
|
||||
# Raw exploration overlay (green/blue/red).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# gdlint:disable=max-file-lines
|
||||
class_name CharacterCreation
|
||||
extends Control
|
||||
## #705: Character creation screen.
|
||||
@@ -105,18 +106,14 @@ const PALETTE_COLORS: Array[Array] = [
|
||||
const PALETTE_COLS := 9
|
||||
const RECENT_SLOTS := 9
|
||||
|
||||
# --- @onready references to .tscn nodes ---
|
||||
@onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport
|
||||
@onready var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
|
||||
@onready var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
|
||||
@onready var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
|
||||
@onready var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
|
||||
@onready var _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
|
||||
var _tab_container: TabContainer = null # resolved in _ready() — @onready path fails when instantiated as child
|
||||
@onready var _footer_back: Button = $Footer/BackBtn
|
||||
@onready var _footer_randomize: Button = $Footer/RandomizeBtn
|
||||
@onready var _footer_start: Button = $Footer/StartBtn
|
||||
@onready var _modal_root: Control = $ColorPickerModal
|
||||
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail)
|
||||
const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level)
|
||||
const CAM_ZOOM_STEP: float = 0.12
|
||||
|
||||
const MANIFEST_PATH := "res://assets/characters/manifest.json"
|
||||
|
||||
const SCREENSHOT_DIR := "user://screenshots/"
|
||||
const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"]
|
||||
|
||||
# --- Descriptor and preview state ---
|
||||
var _descriptor: CharacterVisualDescriptor
|
||||
@@ -125,9 +122,6 @@ var _facing_idx: int = 0 # index into CARDINAL_DIRS (0 = south, default face
|
||||
var _cam_pitch_idx: int = 0 # 0=frontal(-5°), 1=dramatic(-30°), 2=overhead(-80°)
|
||||
var _cam_zoom: float = 1.0 # 1.0 = default distance, <1.0 = zoomed in
|
||||
var _cam_zoom_offset: Vector3 = Vector3.ZERO # camera offset toward cursor when zoomed
|
||||
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail)
|
||||
const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level)
|
||||
const CAM_ZOOM_STEP: float = 0.12
|
||||
|
||||
# --- Tab active slot state ---
|
||||
var _active_clothing_slot: String = "torso"
|
||||
@@ -197,7 +191,31 @@ var _tab_grids: Array[GridContainer] = [null, null, null, null, null]
|
||||
# --- Asset manifest (loaded once, replaces filesystem scanning) ---
|
||||
var _manifest: Dictionary = {}
|
||||
|
||||
const MANIFEST_PATH := "res://assets/characters/manifest.json"
|
||||
# --- Debug tab state ---
|
||||
var _debug_toggles: Dictionary = {} # seg_name -> CheckButton
|
||||
|
||||
# --- Screenshot / automated testing state ---
|
||||
var _screenshot_delay_frames: int = 5 # wait N frames for scene to render
|
||||
var _screenshot_pending: bool = false
|
||||
var _screenshot_frame_count: int = 0
|
||||
var _quit_after_screenshot: bool = false
|
||||
var _screenshot_cardinals: bool = false
|
||||
var _screenshot_cardinal_idx: int = 0
|
||||
|
||||
# resolved in _ready() — @onready path fails when instantiated as child
|
||||
var _tab_container: TabContainer = null
|
||||
|
||||
# --- @onready references to .tscn nodes ---
|
||||
@onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport
|
||||
@onready var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
|
||||
@onready var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
|
||||
@onready var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
|
||||
@onready var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
|
||||
@onready var _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
|
||||
@onready var _footer_back: Button = $Footer/BackBtn
|
||||
@onready var _footer_randomize: Button = $Footer/RandomizeBtn
|
||||
@onready var _footer_start: Button = $Footer/StartBtn
|
||||
@onready var _modal_root: Control = $ColorPickerModal
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -341,7 +359,8 @@ func _cam_zoom_toward_cursor(_screen_pos: Vector2, zoom_delta: float) -> void:
|
||||
if _char_visual and _char_visual._skeleton:
|
||||
var head_idx := _char_visual._skeleton.find_bone("Head")
|
||||
if head_idx >= 0:
|
||||
var head_pos := _char_visual._skeleton.global_transform * _char_visual._skeleton.get_bone_global_pose(head_idx).origin
|
||||
var head_pos := _char_visual._skeleton.global_transform \
|
||||
* _char_visual._skeleton.get_bone_global_pose(head_idx).origin
|
||||
# Blend from body center toward head as zoom increases
|
||||
var blend := 1.0 - _cam_zoom # 0 at default, 0.75 at max zoom
|
||||
_cam_zoom_offset.y = (head_pos.y - CAM_TARGET_HEIGHT) * blend
|
||||
@@ -669,6 +688,11 @@ func _build_hair_color_dock() -> Control:
|
||||
func(c): _on_hair_primary_changed(c))
|
||||
row.add_child(_hair_primary_swatch)
|
||||
|
||||
# #719 (Option B): highlight is auto-derived from primary — display-only, not editable.
|
||||
_hair_highlight_swatch = _make_display_swatch(
|
||||
_derive_hair_highlight(_descriptor.hair_tint), "Highlight")
|
||||
row.add_child(_hair_highlight_swatch)
|
||||
|
||||
_eyebrow_tint_swatch = _make_color_swatch(_descriptor.hair_tint, "Brows ●",
|
||||
func(c): _on_eyebrow_tint_changed(c))
|
||||
row.add_child(_eyebrow_tint_swatch)
|
||||
@@ -1079,15 +1103,6 @@ func _update_accessory_item_btns() -> void:
|
||||
# Screenshot & automated testing
|
||||
# =============================================================================
|
||||
|
||||
const SCREENSHOT_DIR := "user://screenshots/"
|
||||
var _screenshot_delay_frames: int = 5 # wait N frames for scene to render
|
||||
var _screenshot_pending: bool = false
|
||||
var _screenshot_frame_count: int = 0
|
||||
var _quit_after_screenshot: bool = false
|
||||
var _screenshot_cardinals: bool = false
|
||||
var _screenshot_cardinal_idx: int = 0
|
||||
const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"]
|
||||
|
||||
func _schedule_screenshot() -> void:
|
||||
_screenshot_pending = true
|
||||
_screenshot_frame_count = 0
|
||||
@@ -1126,8 +1141,7 @@ func _take_screenshot(suffix: String = "") -> void:
|
||||
# More directions to capture
|
||||
_schedule_screenshot()
|
||||
return
|
||||
else:
|
||||
_screenshot_cardinals = false
|
||||
_screenshot_cardinals = false
|
||||
|
||||
if _quit_after_screenshot:
|
||||
get_tree().quit()
|
||||
@@ -1198,8 +1212,6 @@ func _load_test_config() -> void:
|
||||
# Debug tab — segment visibility toggles
|
||||
# =============================================================================
|
||||
|
||||
var _debug_toggles: Dictionary = {} # seg_name -> CheckButton
|
||||
|
||||
func _build_debug_tab(tab: Control) -> void:
|
||||
var vbox := _make_tab_vbox(tab)
|
||||
|
||||
@@ -1496,7 +1508,7 @@ func _input(event: InputEvent) -> void:
|
||||
_cam_zoom_toward_cursor(mb.position, -CAM_ZOOM_STEP)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
_cam_zoom_toward_cursor(mb.position, CAM_ZOOM_STEP)
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
@@ -1840,45 +1852,6 @@ func _apply_search_filter(grid: GridContainer, query: String) -> void:
|
||||
child.visible = lower_q.is_empty() or (child as Button).text.to_lower().contains(lower_q)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Asset scanning helpers
|
||||
# =============================================================================
|
||||
|
||||
## Scan a directory for subdirectory names (item_id directories like clothing/coveralls_basic/).
|
||||
## Returns fallback list if the directory is absent or empty.
|
||||
## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK).
|
||||
static func _scan_subdirs(dir_path: String, fallback: Array) -> Array:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
return fallback
|
||||
var ids: Array = []
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while name != "":
|
||||
if dir.current_is_dir() and not name.begins_with("."):
|
||||
ids.append(name)
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return ids if not ids.is_empty() else fallback
|
||||
|
||||
|
||||
## Scan a directory for .glb asset IDs. Returns fallback list if directory absent.
|
||||
## TODO: replace with manifest JSON for export builds (DirAccess won't list res:// in PCK).
|
||||
static func _scan_asset_ids(dir_path: String, ext: String, fallback: Array) -> Array:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
return fallback
|
||||
var ids: Array = []
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while name != "":
|
||||
if not dir.current_is_dir() and name.ends_with(ext):
|
||||
ids.append(name.get_basename())
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return ids if not ids.is_empty() else fallback
|
||||
|
||||
|
||||
func _get_clothing_ids_for_slot(slot: String) -> Array:
|
||||
# Clothing items from manifest — slot assignment is explicit, not prefix-based.
|
||||
var clothing_data: Variant = _manifest.get("clothing", {})
|
||||
@@ -1907,7 +1880,7 @@ func _get_accessory_ids_for_slot(slot: String) -> Array:
|
||||
"wrist_l", "wrist_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("wrist"))
|
||||
"earring_l", "earring_r": return all_ids.filter(func(id: String) -> bool: return id.begins_with("earring"))
|
||||
"necklace": return all_ids.filter(func(id: String) -> bool: return id.begins_with("necklace"))
|
||||
_: return all_ids
|
||||
_: return all_ids # gdlint:ignore = max-returns
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -6,7 +6,7 @@ extends Control
|
||||
##
|
||||
## Spec ref: D-030 (testability), #503, Sprint 10 Completion Proof.
|
||||
|
||||
const _ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
|
||||
const ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd")
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
|
||||
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
|
||||
@@ -26,7 +26,7 @@ var _cached_font: Font = null # Cached to avoid per-frame theme lookup
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
visible = false
|
||||
_evaluator = _ChecklistEvaluator.new()
|
||||
_evaluator = ChecklistEvaluator.new()
|
||||
_cached_font = get_theme_default_font()
|
||||
|
||||
|
||||
|
||||
@@ -232,11 +232,15 @@ func _draw_stats_panel() -> void:
|
||||
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():
|
||||
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)
|
||||
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():
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@@ -335,7 +339,8 @@ func _draw_npc_paths(canvas_xf: Transform2D) -> void:
|
||||
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_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)
|
||||
|
||||
|
||||
|
||||
+29
-29
@@ -21,9 +21,26 @@ 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
|
||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
|
||||
|
||||
const FADE_IN: float = 0.2
|
||||
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
|
||||
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
|
||||
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
|
||||
const MAX_WIDTH_PX: float = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29)
|
||||
const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending
|
||||
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
|
||||
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
|
||||
const PLAYER_NAME: String = "You"
|
||||
const PASSIVE_GLYPH: String = "\u2503 " # ┃ prefix for overheard lines (Araminta review)
|
||||
const PASSIVE_DESATURATION: float = 0.4 # Desaturate passive name colours by this factor
|
||||
const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness against dark BG
|
||||
|
||||
# D-064: movement actions that trigger walk-away
|
||||
const _WALK_AWAY_ACTIONS: Array[StringName] = [
|
||||
&"move_north", &"move_south", &"move_east", &"move_west",
|
||||
&"move_northeast", &"move_southeast", &"move_southwest", &"move_northwest",
|
||||
]
|
||||
|
||||
# -- Log state --
|
||||
# Entry format (legacy): {speaker: String, target: String, text, is_passive, pinned, timestamp_msec}
|
||||
@@ -67,26 +84,9 @@ var _passive_opacity: float = 0.9
|
||||
var _entry_lifetime: float = 45.0
|
||||
var _entry_fade: float = 5.0
|
||||
|
||||
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
|
||||
|
||||
const FADE_IN: float = 0.2
|
||||
const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away
|
||||
const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible
|
||||
const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height
|
||||
const MAX_WIDTH_PX: float = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29)
|
||||
const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending
|
||||
const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat
|
||||
const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat"
|
||||
const PLAYER_NAME: String = "You"
|
||||
const PASSIVE_GLYPH: String = "\u2503 " # ┃ prefix for overheard lines (Araminta review)
|
||||
const PASSIVE_DESATURATION: float = 0.4 # Desaturate passive name colours by this factor
|
||||
const MIN_CONTRAST_LUMINANCE: float = 0.25 # Floor for name colour brightness against dark BG
|
||||
|
||||
# D-064: movement actions that trigger walk-away
|
||||
const _WALK_AWAY_ACTIONS: Array[StringName] = [
|
||||
&"move_north", &"move_south", &"move_east", &"move_west",
|
||||
&"move_northeast", &"move_southeast", &"move_southwest", &"move_northwest",
|
||||
]
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -197,7 +197,8 @@ func _update_layout() -> void:
|
||||
## Active conversation entries are pinned (no timeout) while _in_player_conversation.
|
||||
## speaker_entity_id/target_entity_id: optional entity IDs for stable color lookup (#573).
|
||||
## TODO Phase 2: 6 positional params is unwieldy — consider dictionary-options overload.
|
||||
func append_line(speaker: String, target: String, text: String, is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void:
|
||||
func append_line(speaker: String, target: String, text: String,
|
||||
is_passive: bool = false, speaker_entity_id: int = -1, target_entity_id: int = -1) -> void:
|
||||
var pinned := not is_passive and _in_player_conversation
|
||||
var entry: Dictionary = {
|
||||
"speaker": speaker,
|
||||
@@ -572,11 +573,10 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
prefix, sc, speaker, txc, text
|
||||
]
|
||||
else:
|
||||
# Full "Speaker → Target: text" for overheard
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
prefix, sc, speaker, ac, tc, target, txc, text
|
||||
]
|
||||
# Full "Speaker → Target: text" for overheard
|
||||
return "%s[color=%s][b]%s[/b][/color][color=%s] \u2192 [/color][color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||
prefix, sc, speaker, ac, tc, target, txc, text
|
||||
]
|
||||
|
||||
|
||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||
|
||||
@@ -21,12 +21,12 @@ const CONFIDENCE_ALPHA: Dictionary = {
|
||||
"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
|
||||
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
modulate.a = 0.0
|
||||
|
||||
@@ -114,7 +114,8 @@ func _draw() -> void:
|
||||
# PB text (different color)
|
||||
if not pb_text.is_empty():
|
||||
var pb_color: Color = NEW_PB_COLOR if _new_pb_flash > 0.0 else PB_COLOR
|
||||
draw_string(font, Vector2(PADDING.x + timer_size.x, y_offset), pb_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, pb_color)
|
||||
draw_string(font, Vector2(PADDING.x + timer_size.x, y_offset), pb_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, pb_color)
|
||||
|
||||
|
||||
static func _format_time(seconds: float) -> String:
|
||||
|
||||
@@ -15,7 +15,7 @@ const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing not
|
||||
const TPT_DEGRADATION_THRESHOLD := 0.4 # fraction — >40% sustained drop → yellow
|
||||
|
||||
## Benchmark cache path — reads from PlatformInfo for cross-platform correctness.
|
||||
var BENCHMARK_CACHE_PATH: String:
|
||||
var benchmark_cache_path: String:
|
||||
get:
|
||||
return PlatformInfo.benchmark_cache_path
|
||||
|
||||
@@ -45,10 +45,9 @@ func _ready() -> void:
|
||||
func classify_ram(free_mb: float) -> String:
|
||||
if free_mb >= RAM_PASS_THRESHOLD_MB:
|
||||
return "pass"
|
||||
elif free_mb >= RAM_MARGINAL_THRESHOLD_MB:
|
||||
if free_mb >= RAM_MARGINAL_THRESHOLD_MB:
|
||||
return "marginal"
|
||||
else:
|
||||
return "fail"
|
||||
return "fail"
|
||||
|
||||
|
||||
## Query RAM via PlatformInfo and return classification + raw MB value.
|
||||
@@ -65,10 +64,9 @@ func check_ram() -> Dictionary:
|
||||
func classify_tpt(tps: float) -> String:
|
||||
if tps >= TPT_GREEN_THRESHOLD:
|
||||
return "green"
|
||||
elif tps >= TPT_YELLOW_THRESHOLD:
|
||||
if tps >= TPT_YELLOW_THRESHOLD:
|
||||
return "yellow"
|
||||
else:
|
||||
return "red"
|
||||
return "red"
|
||||
|
||||
|
||||
## Read the cached TPT benchmark result written by the server on first model load.
|
||||
@@ -192,12 +190,20 @@ func _send_settings_change(enabled: bool) -> void:
|
||||
## Returns empty string when no message is needed.
|
||||
func status_message(ram_classification: String, tpt_classification: String, free_mb: float, tps: float) -> String:
|
||||
if ram_classification == "fail":
|
||||
return "AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available. Close other applications and try again, or leave the setting off — the game is complete either way." % free_mb
|
||||
return (
|
||||
"AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available."
|
||||
+ " Close other applications and try again, or leave the setting off — the game is complete either way."
|
||||
) % free_mb
|
||||
if ram_classification == "marginal":
|
||||
return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb
|
||||
match tpt_classification:
|
||||
"yellow":
|
||||
return "Running at %.0f t/s — pre-voicing will work for main characters and key scenes. Background NPCs may show base text until the queue catches up." % tps
|
||||
return (
|
||||
"Running at %.0f t/s — pre-voicing will work for main characters and key scenes."
|
||||
+ " Background NPCs may show base text until the queue catches up."
|
||||
) % tps
|
||||
"red":
|
||||
return "Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours." % tps
|
||||
return (
|
||||
"Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours."
|
||||
) % tps
|
||||
return ""
|
||||
|
||||
+6
-1
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
[gd_scene load_steps=3 format=3 uid="uid://cq1y5w3hmxr8b"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"]
|
||||
[ext_resource type="PackedScene" path="res://ui/star_map.tscn" id="2_starmap"]
|
||||
|
||||
[node name="HUD" type="Control"]
|
||||
layout_mode = 3
|
||||
@@ -36,3 +37,7 @@ text = "Mode: Baseline"
|
||||
[node name="TimeLabel" type="Label" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Time: 08:00"
|
||||
|
||||
; #674: Star map insert — concentric hop-ring view, hidden by default, toggled via keybind
|
||||
[node name="StarMap" parent="." instance=ExtResource("2_starmap")]
|
||||
visible = false
|
||||
|
||||
@@ -32,6 +32,8 @@ 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
|
||||
|
||||
var _last_screen_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBox
|
||||
|
||||
|
||||
@@ -40,9 +42,6 @@ func _ready() -> void:
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
|
||||
var _last_screen_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _showing:
|
||||
_update_screen_position()
|
||||
|
||||
@@ -8,7 +8,8 @@ extends PanelContainer
|
||||
# v0.2: Will be replaced/extended with radial verb menu.
|
||||
# Public interface: get_interaction_target(), get_selected_verb()
|
||||
|
||||
@onready var prompt_label: Label = $MarginContainer/PromptLabel
|
||||
const FADE_IN: float = 0.15
|
||||
const FADE_OUT: float = 0.15
|
||||
|
||||
var _is_showing: bool = false
|
||||
var _active_tween: Tween = null
|
||||
@@ -16,8 +17,7 @@ var _current_target_id: int = -1
|
||||
# OQ-07 (#522): when false, prompt is suppressed (z-layer 6 insert overlay only)
|
||||
var _insert_active: bool = true
|
||||
|
||||
const FADE_IN: float = 0.15
|
||||
const FADE_OUT: float = 0.15
|
||||
@onready var prompt_label: Label = $MarginContainer/PromptLabel
|
||||
|
||||
func _ready() -> void:
|
||||
modulate.a = 0.0
|
||||
|
||||
@@ -94,7 +94,8 @@ func _draw() -> void:
|
||||
|
||||
# Hotkey number (top-left corner)
|
||||
var hotkey := str(slot_idx + 1)
|
||||
draw_string(font, pos + Vector2(3, HOTKEY_SIZE + 2), hotkey, HORIZONTAL_ALIGNMENT_LEFT, -1, HOTKEY_SIZE, SLOT_TEXT_DIM)
|
||||
draw_string(font, pos + Vector2(3, HOTKEY_SIZE + 2), hotkey,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, HOTKEY_SIZE, SLOT_TEXT_DIM)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
|
||||
@@ -23,13 +23,14 @@ const FADE_OUT: float = 0.25
|
||||
# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower}
|
||||
# Fallback: raw value if key not found (UIStrings returns the key itself).
|
||||
|
||||
var _visible_state: bool = false
|
||||
var _last_rendered_tick: int = -1
|
||||
|
||||
@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
|
||||
@onready var entries_container: VBoxContainer = \
|
||||
$PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
@@ -40,5 +40,5 @@ func show_loading() -> void:
|
||||
|
||||
|
||||
## Hide the loading overlay. success=false is reserved for future failure-state UI.
|
||||
func hide_loading(success: bool = true) -> void:
|
||||
func hide_loading(_success: bool = true) -> void:
|
||||
visible = false
|
||||
|
||||
@@ -16,6 +16,9 @@ const FONT_SIZE_TITLE := 36
|
||||
const FONT_SIZE_SUBTITLE := 14
|
||||
const FONT_SIZE_BTN := 15
|
||||
|
||||
var _char_creation: Control = null
|
||||
var _list_built: bool = false
|
||||
|
||||
@onready var _new_game_btn: Button = $VBox/NewGameBtn
|
||||
@onready var _continue_btn: Button = $VBox/ContinueBtn
|
||||
@onready var _load_game_btn: Button = $VBox/LoadGameBtn
|
||||
@@ -24,8 +27,6 @@ const FONT_SIZE_BTN := 15
|
||||
@onready var _saves_list: VBoxContainer = $LoadGamePanel/VBox/SavesScroll/SavesList
|
||||
@onready var _load_back_btn: Button = $LoadGamePanel/VBox/BackBtn
|
||||
|
||||
var _char_creation: Control = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_new_game_btn.pressed.connect(_on_new_game)
|
||||
@@ -62,7 +63,7 @@ func _show_character_creation() -> void:
|
||||
_char_creation.creation_cancelled.connect(_on_creation_cancelled)
|
||||
|
||||
|
||||
func _on_creation_confirmed(descriptor: CharacterVisualDescriptor) -> void:
|
||||
func _on_creation_confirmed(descriptor) -> void:
|
||||
if _char_creation != null and is_instance_valid(_char_creation):
|
||||
_char_creation.queue_free()
|
||||
_char_creation = null
|
||||
@@ -93,9 +94,6 @@ func _on_continue() -> void:
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
var _list_built: bool = false
|
||||
|
||||
|
||||
func _on_load_game_browse() -> void:
|
||||
if not _list_built:
|
||||
_build_saves_list()
|
||||
|
||||
@@ -38,8 +38,6 @@ 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
|
||||
|
||||
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
|
||||
var _visible: Array[Dictionary] = []
|
||||
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
|
||||
@@ -47,6 +45,8 @@ var _queue: Array[Dictionary] = []
|
||||
# Msec timestamp when the next fade-in may begin (stagger enforcement)
|
||||
var _next_fade_in_msec: float = 0.0
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
pass
|
||||
@@ -85,7 +85,10 @@ func show_notification(text: String) -> void:
|
||||
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}
|
||||
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)
|
||||
@@ -173,13 +176,19 @@ func _retire_slot(slot: Dictionary) -> void:
|
||||
|
||||
func _enqueue(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append({text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile})
|
||||
_queue.append({
|
||||
text = text, duration = duration, priority = priority,
|
||||
is_urgent = is_urgent, lattice_profile = lattice_profile
|
||||
})
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
else:
|
||||
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
|
||||
var lowest := _lowest_priority_idx()
|
||||
if priority >= _queue[lowest].priority:
|
||||
_queue[lowest] = {text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile}
|
||||
_queue[lowest] = {
|
||||
text = text, duration = duration, priority = priority,
|
||||
is_urgent = is_urgent, lattice_profile = lattice_profile
|
||||
}
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
# else: incoming line is strictly lower priority — silently drop; no sort needed
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ const FONT_SIZE := 13
|
||||
const SCROLL_SPEED := 60.0 # pixels per second
|
||||
const BAR_HEIGHT := 28
|
||||
|
||||
@onready var _label: Label = $TickerLabel
|
||||
|
||||
var _text: String = ""
|
||||
var _scroll_x: float = 0.0
|
||||
var _content_width: float = 0.0
|
||||
|
||||
@onready var _label: Label = $TickerLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
@@ -6,6 +6,10 @@ extends Control
|
||||
## Volumes persist via AudioManager._save_prefs() on each slider change.
|
||||
## AI Dialogue toggle persists via ConfigFile (client-local) + ChangeSettings IPC (server SQLite).
|
||||
|
||||
signal closed
|
||||
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
|
||||
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
|
||||
const BORDER_COLOR := Color("#4a9ebb")
|
||||
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
|
||||
@@ -42,10 +46,6 @@ var _ai_check_node: CheckButton = null
|
||||
var _ai_inference_suspended: bool = false # D-138 §8 Layer 3 battery auto-suspend state
|
||||
var _ai_battery_warning_label: Label = null # shown when on battery; toggle stays enabled
|
||||
|
||||
signal closed
|
||||
signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled
|
||||
signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
class_name StarMapRenderer
|
||||
extends Control
|
||||
|
||||
## Star map — concentric hop-ring view of the Settled Reach gate network (#674).
|
||||
## Renders 301 systems as dots on concentric rings (hop distance from Gateway).
|
||||
## Sector-colored: core (white-gold), north (blue), south (orange), east (green), west (tan).
|
||||
##
|
||||
## Data source: res://data/star_map_data.json (generated from star-map.json + systems.db + wiki).
|
||||
## Regenerate with: tooling/generate-star-map-data.py
|
||||
##
|
||||
## D-013: Diegetic neural insert overlay. Accessible from the insert UI.
|
||||
## Parent epic: #51 (Diegetic Insert/Minimap), ticket #674.
|
||||
## Ticket #780: Click-through popup — wiki/GTTR content on system select.
|
||||
|
||||
const DATA_PATH := "res://data/star_map_data.json"
|
||||
|
||||
# Layout
|
||||
const MAP_CENTER_FRACTION := Vector2(0.5, 0.5) # center of control
|
||||
const MIN_RING_RADIUS: float = 30.0 # innermost ring (hop 0 = gateway dot only)
|
||||
const RING_SPACING: float = 22.0 # pixels between hop rings
|
||||
const MAX_HOP_RINGS: int = 24 # max hop distance we render rings for
|
||||
|
||||
# Dot sizing
|
||||
const DOT_RADIUS_HUB: float = 4.5
|
||||
const DOT_RADIUS_JUNCTION: float = 3.5
|
||||
const DOT_RADIUS_DEFAULT: float = 2.5
|
||||
const DOT_RADIUS_DEAD_END: float = 2.0
|
||||
const GATEWAY_RADIUS: float = 6.0
|
||||
|
||||
# Selection
|
||||
const SELECTION_RING_RADIUS: float = 8.0
|
||||
const HIT_RADIUS: float = 10.0 # click tolerance
|
||||
|
||||
# Edge rendering — only shown for selected system (ticket #780 UX rule)
|
||||
const EDGE_WIDTH: float = 0.8
|
||||
const EDGE_SELECTED_ALPHA: float = 0.55
|
||||
|
||||
# Info popup — expanded with wiki/GTTR content (#780)
|
||||
const POPUP_WIDTH: float = 300.0
|
||||
const POPUP_MARGIN: float = 16.0
|
||||
const POPUP_PADDING: float = 12.0
|
||||
const POPUP_LINE_H: float = 17.0
|
||||
const POPUP_GTTR_FONT_SIZE: int = 10
|
||||
const POPUP_GTTR_MAX_LINES: int = 7
|
||||
|
||||
# Colors — sector palette from wireframe
|
||||
const COLOR_BG: Color = Color("#0d1117")
|
||||
const COLOR_RING: Color = Color("#1a2030")
|
||||
const COLOR_RING_MAJOR: Color = Color("#222a3a")
|
||||
const COLOR_GATEWAY: Color = Color("#f0d060")
|
||||
const COLOR_SELECTION: Color = Color("#f0d060")
|
||||
const COLOR_TEXT: Color = Color("#c8d0e0")
|
||||
const COLOR_TEXT_DIM: Color = Color("#667788")
|
||||
const COLOR_INFO_BG: Color = Color(0.05, 0.08, 0.14, 0.92)
|
||||
|
||||
const SECTOR_COLORS: Dictionary = {
|
||||
"core": Color("#c8d0e0"),
|
||||
"north_reach": Color("#4488aa"),
|
||||
"south_reach": Color("#aa6644"),
|
||||
"east_reach": Color("#44aa66"),
|
||||
"west_reach": Color("#aa8844"),
|
||||
"deep_frontier": Color("#556677"),
|
||||
"unknown": Color("#445566"),
|
||||
}
|
||||
|
||||
const SECTOR_LABELS: Dictionary = {
|
||||
"north_reach": "NORTH REACH",
|
||||
"south_reach": "SOUTH REACH",
|
||||
"east_reach": "EAST REACH",
|
||||
"west_reach": "WEST REACH",
|
||||
}
|
||||
|
||||
# Quadrant angles for sector placement (radians, 0 = right, counterclockwise)
|
||||
# North = top (-PI/2), East = right (0), South = bottom (PI/2), West = left (PI)
|
||||
const SECTOR_ANGLE_CENTER: Dictionary = {
|
||||
"north_reach": -PI / 2.0,
|
||||
"east_reach": 0.0,
|
||||
"south_reach": PI / 2.0,
|
||||
"west_reach": PI,
|
||||
}
|
||||
const SECTOR_ANGLE_SPREAD: float = PI / 2.5 # each sector occupies ~72° of arc
|
||||
const CORE_ANGLE_SPREAD: float = TAU # core systems spread full circle
|
||||
const DEEP_FRONTIER_ANGLE_SPREAD: float = TAU # deep frontier wraps entire outer edge
|
||||
|
||||
# Pan/zoom
|
||||
const ZOOM_MIN: float = 0.3
|
||||
const ZOOM_MAX: float = 3.0
|
||||
const ZOOM_STEP: float = 0.15
|
||||
|
||||
# Internal state
|
||||
var _nodes: Array = []
|
||||
var _edges: Array = []
|
||||
var _node_positions: Dictionary = {} # system_id -> Vector2 (screen coords relative to map center)
|
||||
var _node_lookup: Dictionary = {} # system_id -> node dict
|
||||
var _selected_system: String = ""
|
||||
var _hovered_system: String = ""
|
||||
|
||||
var _zoom: float = 1.0
|
||||
var _pan_offset: Vector2 = Vector2.ZERO
|
||||
var _is_panning: bool = false
|
||||
var _pan_start: Vector2 = Vector2.ZERO
|
||||
var _pan_start_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
var _data_loaded: bool = false
|
||||
var _insert_active: bool = true
|
||||
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_load_data()
|
||||
if _data_loaded:
|
||||
_compute_layout()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if _dirty and _data_loaded:
|
||||
queue_redraw()
|
||||
_dirty = false
|
||||
|
||||
|
||||
## Called from main.gd when insert state changes.
|
||||
## Only force-hides when insert is inactive. Does NOT auto-show — star map is
|
||||
## modal (player opens via toggle_visible()), not always-on like the minimap.
|
||||
func set_insert_active(active: bool) -> void:
|
||||
_insert_active = active
|
||||
if not active:
|
||||
visible = false
|
||||
|
||||
|
||||
## Toggle visibility (e.g., from a keybind or button).
|
||||
func toggle_visible() -> void:
|
||||
visible = not visible
|
||||
if visible:
|
||||
_dirty = true
|
||||
|
||||
|
||||
## Return the currently selected system data, or empty dict.
|
||||
func get_selected_system() -> Dictionary:
|
||||
return _node_lookup.get(_selected_system, {})
|
||||
|
||||
|
||||
## Return total system count.
|
||||
func get_system_count() -> int:
|
||||
return _nodes.size()
|
||||
|
||||
|
||||
## Return total edge count.
|
||||
func get_edge_count() -> int:
|
||||
return _edges.size()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data loading
|
||||
# =============================================================================
|
||||
|
||||
func _load_data() -> void:
|
||||
if not FileAccess.file_exists(DATA_PATH):
|
||||
push_warning("StarMapRenderer: data file not found at %s" % DATA_PATH)
|
||||
return
|
||||
var file := FileAccess.open(DATA_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("StarMapRenderer: could not open %s" % DATA_PATH)
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
push_warning("StarMapRenderer: invalid JSON in %s" % DATA_PATH)
|
||||
return
|
||||
var data := parsed as Dictionary
|
||||
_nodes = data.get("nodes", [])
|
||||
_edges = data.get("edges", [])
|
||||
for node: Dictionary in _nodes:
|
||||
_node_lookup[node.get("system_id", "")] = node
|
||||
_data_loaded = true
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Layout — place systems on concentric rings by hop distance
|
||||
# =============================================================================
|
||||
|
||||
func _compute_layout() -> void:
|
||||
_node_positions.clear()
|
||||
|
||||
# Group nodes by hop distance
|
||||
var rings: Dictionary = {} # hop -> Array of nodes
|
||||
for node: Dictionary in _nodes:
|
||||
var hop: int = int(node.get("hop_distance", 0))
|
||||
if not rings.has(hop):
|
||||
rings[hop] = []
|
||||
rings[hop].append(node)
|
||||
|
||||
# Place each ring
|
||||
for hop: int in rings:
|
||||
var ring_nodes: Array = rings[hop]
|
||||
var radius: float = MIN_RING_RADIUS + hop * RING_SPACING
|
||||
|
||||
if hop == 0:
|
||||
# Gateway at center
|
||||
for node: Dictionary in ring_nodes:
|
||||
_node_positions[node["system_id"]] = Vector2.ZERO
|
||||
continue
|
||||
|
||||
# Sort nodes within ring by sector for angular grouping
|
||||
ring_nodes.sort_custom(_sort_by_sector_angle)
|
||||
|
||||
# Distribute nodes within their sector's angular range
|
||||
var sector_groups: Dictionary = {}
|
||||
for node: Dictionary in ring_nodes:
|
||||
var sector: String = node.get("geographic_sector", "unknown")
|
||||
if not sector_groups.has(sector):
|
||||
sector_groups[sector] = []
|
||||
sector_groups[sector].append(node)
|
||||
|
||||
for sector: String in sector_groups:
|
||||
var group: Array = sector_groups[sector]
|
||||
var count: int = group.size()
|
||||
|
||||
var center_angle: float
|
||||
var spread: float
|
||||
if sector == "core":
|
||||
center_angle = 0.0
|
||||
spread = CORE_ANGLE_SPREAD
|
||||
elif sector == "deep_frontier":
|
||||
center_angle = 0.0
|
||||
spread = DEEP_FRONTIER_ANGLE_SPREAD
|
||||
elif SECTOR_ANGLE_CENTER.has(sector):
|
||||
center_angle = SECTOR_ANGLE_CENTER[sector]
|
||||
spread = SECTOR_ANGLE_SPREAD
|
||||
else:
|
||||
center_angle = 0.0
|
||||
spread = TAU
|
||||
|
||||
# Distribute evenly within sector arc, with deterministic offset per system
|
||||
for i: int in range(count):
|
||||
var node: Dictionary = group[i]
|
||||
var t: float
|
||||
if count == 1:
|
||||
t = 0.0
|
||||
else:
|
||||
t = float(i) / float(count) - 0.5 # -0.5 to +0.5
|
||||
var angle: float = center_angle + t * spread
|
||||
# Add small per-node jitter based on system_id hash for visual variety
|
||||
var jitter: float = _system_hash(node["system_id"]) * 0.08
|
||||
angle += jitter
|
||||
# Slight radial variation to avoid perfect circles
|
||||
var r_var: float = radius + _system_hash(node["system_id"] + "r") * RING_SPACING * 0.3
|
||||
_node_positions[node["system_id"]] = Vector2(cos(angle), sin(angle)) * r_var
|
||||
|
||||
|
||||
func _sort_by_sector_angle(a: Dictionary, b: Dictionary) -> bool:
|
||||
var sa: float = _sector_sort_key(a)
|
||||
var sb: float = _sector_sort_key(b)
|
||||
if sa != sb:
|
||||
return sa < sb
|
||||
return a.get("system_id", "") < b.get("system_id", "")
|
||||
|
||||
|
||||
func _sector_sort_key(node: Dictionary) -> float:
|
||||
var sector: String = node.get("geographic_sector", "unknown")
|
||||
match sector:
|
||||
"core": return 0.0
|
||||
"north_reach": return 1.0
|
||||
"east_reach": return 2.0
|
||||
"south_reach": return 3.0
|
||||
"west_reach": return 4.0
|
||||
"deep_frontier": return 5.0
|
||||
_: return 6.0 # gdlint:ignore = max-returns
|
||||
|
||||
|
||||
## Deterministic float in [-1, 1] from a string key.
|
||||
func _system_hash(key: String) -> float:
|
||||
var h: int = key.hash() & 0x7FFFFFFF # mask to 31-bit positive range
|
||||
return float(h) / 2147483647.0 * 2.0 - 1.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Drawing
|
||||
# =============================================================================
|
||||
|
||||
func _draw() -> void:
|
||||
if not _data_loaded:
|
||||
return
|
||||
|
||||
var sz: Vector2 = get_rect().size
|
||||
var center: Vector2 = sz * MAP_CENTER_FRACTION + _pan_offset
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG)
|
||||
|
||||
# Hop rings (concentric circles)
|
||||
_draw_rings(center)
|
||||
|
||||
# Sector labels
|
||||
_draw_sector_labels(center)
|
||||
|
||||
# Edges — only draw from selected system (UX rule: full edge web is too dense)
|
||||
if _selected_system != "":
|
||||
_draw_edges(center)
|
||||
|
||||
# System dots
|
||||
_draw_systems(center)
|
||||
|
||||
# Selection highlight
|
||||
if _selected_system != "":
|
||||
_draw_selection(center)
|
||||
|
||||
# Info panel for selected system
|
||||
if _selected_system != "":
|
||||
_draw_info_panel(sz)
|
||||
|
||||
# Title
|
||||
_draw_title()
|
||||
|
||||
|
||||
func _draw_rings(center: Vector2) -> void:
|
||||
for hop: int in range(MAX_HOP_RINGS + 1):
|
||||
var radius: float = (MIN_RING_RADIUS + hop * RING_SPACING) * _zoom
|
||||
if radius < 1.0 or radius > 2000.0:
|
||||
continue
|
||||
var color: Color = COLOR_RING_MAJOR if hop % 5 == 0 else COLOR_RING
|
||||
draw_arc(center, radius, 0.0, TAU, 64, color, 0.5 if hop % 5 == 0 else 0.3, true)
|
||||
|
||||
|
||||
func _draw_sector_labels(center: Vector2) -> void:
|
||||
var label_radius: float = (MIN_RING_RADIUS + 12 * RING_SPACING) * _zoom
|
||||
for sector: String in SECTOR_LABELS:
|
||||
var angle: float = SECTOR_ANGLE_CENTER.get(sector, 0.0)
|
||||
var pos: Vector2 = center + Vector2(cos(angle), sin(angle)) * label_radius
|
||||
var label: String = SECTOR_LABELS[sector]
|
||||
var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM)
|
||||
var font := get_theme_default_font()
|
||||
var font_size: int = 10
|
||||
var text_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
||||
draw_string(font, pos - text_size / 2.0, label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, color)
|
||||
|
||||
|
||||
func _draw_systems(center: Vector2) -> void:
|
||||
for node: Dictionary in _nodes:
|
||||
var sid: String = node.get("system_id", "")
|
||||
if not _node_positions.has(sid):
|
||||
continue
|
||||
var pos: Vector2 = center + _node_positions[sid] * _zoom
|
||||
var sector: String = node.get("geographic_sector", "unknown")
|
||||
var topology: String = node.get("gate_topology", "")
|
||||
var color: Color = SECTOR_COLORS.get(sector, COLOR_TEXT_DIM)
|
||||
var radius: float = _dot_radius(topology)
|
||||
|
||||
# Gateway gets special treatment
|
||||
if node.get("is_gateway", false):
|
||||
color = COLOR_GATEWAY
|
||||
radius = GATEWAY_RADIUS
|
||||
|
||||
# Dim deep frontier slightly
|
||||
if sector == "deep_frontier":
|
||||
color.a = 0.7
|
||||
|
||||
# Hover highlight
|
||||
if sid == _hovered_system and sid != _selected_system:
|
||||
draw_arc(pos, radius + 3.0, 0.0, TAU, 16, Color(color.r, color.g, color.b, 0.4), 1.0, true)
|
||||
|
||||
draw_circle(pos, radius, color)
|
||||
|
||||
|
||||
func _draw_edges(center: Vector2) -> void:
|
||||
# Only draw edges connected to the selected system (full web is unreadable at 301 systems)
|
||||
var node: Dictionary = _node_lookup.get(_selected_system, {})
|
||||
var adj: Array = node.get("adjacent_systems", [])
|
||||
if adj.is_empty():
|
||||
return
|
||||
var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, EDGE_SELECTED_ALPHA)
|
||||
var sel_pos: Vector2 = center + _node_positions.get(_selected_system, Vector2.ZERO) * _zoom
|
||||
for neighbor_id: String in adj:
|
||||
if not _node_positions.has(neighbor_id):
|
||||
continue
|
||||
var neighbor_pos: Vector2 = center + _node_positions[neighbor_id] * _zoom
|
||||
draw_line(sel_pos, neighbor_pos, color, EDGE_WIDTH, true)
|
||||
|
||||
|
||||
func _draw_selection(center: Vector2) -> void:
|
||||
if not _node_positions.has(_selected_system):
|
||||
return
|
||||
var pos: Vector2 = center + _node_positions[_selected_system] * _zoom
|
||||
draw_arc(pos, SELECTION_RING_RADIUS, 0.0, TAU, 24, COLOR_SELECTION, 1.2, true)
|
||||
|
||||
# Draw label next to selection
|
||||
var node: Dictionary = _node_lookup.get(_selected_system, {})
|
||||
var label: String = node.get("proper_name", _selected_system)
|
||||
if label.is_empty():
|
||||
label = _selected_system
|
||||
var font := get_theme_default_font()
|
||||
draw_string(font, pos + Vector2(SELECTION_RING_RADIUS + 4, 4), label,
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION)
|
||||
|
||||
|
||||
func _draw_info_panel(sz: Vector2) -> void:
|
||||
var node: Dictionary = _node_lookup.get(_selected_system, {})
|
||||
if node.is_empty():
|
||||
return
|
||||
|
||||
var font := get_theme_default_font()
|
||||
var panel_w: float = POPUP_WIDTH
|
||||
var pad: float = POPUP_PADDING
|
||||
var inner_w: float = panel_w - pad * 2.0
|
||||
|
||||
# Measure GTTR text height first so we can size the panel
|
||||
var gttr: String = node.get("gttr_excerpt", "")
|
||||
# Strip markdown bold markers for display
|
||||
gttr = gttr.replace("**", "")
|
||||
var gttr_block_h: float = 0.0
|
||||
if not gttr.is_empty():
|
||||
var gttr_size: Vector2 = font.get_multiline_string_size(
|
||||
gttr, HORIZONTAL_ALIGNMENT_LEFT, inner_w, POPUP_GTTR_FONT_SIZE,
|
||||
POPUP_GTTR_MAX_LINES)
|
||||
gttr_block_h = gttr_size.y + 6.0
|
||||
|
||||
# Fixed rows: name, id, star+hop, corridor, bodies+pop, adjacent, separator lines
|
||||
var fixed_h: float = (
|
||||
POPUP_LINE_H * 2.0 # name + id
|
||||
+ 4.0 # separator
|
||||
+ POPUP_LINE_H * 3.0 # star+hop, corridor, bodies+pop
|
||||
+ 4.0 # separator
|
||||
+ gttr_block_h
|
||||
+ 4.0 # separator (before adjacent)
|
||||
+ POPUP_LINE_H # adjacent systems label
|
||||
+ pad * 2.0
|
||||
)
|
||||
var panel_h: float = fixed_h
|
||||
|
||||
var panel_pos := Vector2(sz.x - panel_w - POPUP_MARGIN, POPUP_MARGIN)
|
||||
var x: float = panel_pos.x + pad
|
||||
var y: float = panel_pos.y + pad + POPUP_LINE_H # baseline offset
|
||||
|
||||
# Background + border
|
||||
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
|
||||
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.15), false, 1.0)
|
||||
|
||||
# ── System name ──────────────────────────────────────────────────────────
|
||||
var sys_name: String = node.get("proper_name", "")
|
||||
if sys_name.is_empty():
|
||||
sys_name = node.get("system_id", "Unknown")
|
||||
draw_string(font, Vector2(x, y), sys_name, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 15, COLOR_TEXT)
|
||||
y += POPUP_LINE_H
|
||||
|
||||
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
|
||||
y += POPUP_LINE_H
|
||||
|
||||
# Separator
|
||||
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
||||
y += 6.0
|
||||
|
||||
# ── Stats block ──────────────────────────────────────────────────────────
|
||||
var star_type: String = node.get("star_type", "")
|
||||
var hop: int = int(node.get("hop_distance", 0))
|
||||
var stat_line_1: String
|
||||
if star_type.is_empty():
|
||||
stat_line_1 = "Hop %d from Gateway" % hop
|
||||
else:
|
||||
stat_line_1 = "%s star · hop %d" % [star_type, hop]
|
||||
draw_string(font, Vector2(x, y), stat_line_1, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
|
||||
y += POPUP_LINE_H
|
||||
|
||||
var sector_str: String = node.get("geographic_sector", "unknown").replace("_", " ").to_upper()
|
||||
var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
|
||||
draw_string(font, Vector2(x, y), sector_str + " corridor", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, sector_color)
|
||||
y += POPUP_LINE_H
|
||||
|
||||
var bodies: String = node.get("bodies", "")
|
||||
var population: String = node.get("population", "")
|
||||
var stat_line_3: String
|
||||
if not bodies.is_empty() and not population.is_empty():
|
||||
stat_line_3 = "%s · pop %s" % [bodies, population]
|
||||
elif not bodies.is_empty():
|
||||
stat_line_3 = bodies
|
||||
elif not population.is_empty():
|
||||
stat_line_3 = "Population: " + population
|
||||
else:
|
||||
stat_line_3 = "%d aperture%s · %s" % [
|
||||
int(node.get("aperture_count", 0)),
|
||||
"s" if int(node.get("aperture_count", 0)) != 1 else "",
|
||||
node.get("gate_topology", "")]
|
||||
draw_string(font, Vector2(x, y), stat_line_3, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
|
||||
y += POPUP_LINE_H
|
||||
|
||||
# ── GTTR excerpt ─────────────────────────────────────────────────────────
|
||||
if not gttr.is_empty():
|
||||
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
||||
y += 6.0
|
||||
draw_multiline_string(font, Vector2(x, y), gttr, HORIZONTAL_ALIGNMENT_LEFT,
|
||||
inner_w, POPUP_GTTR_FONT_SIZE, POPUP_GTTR_MAX_LINES,
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.75))
|
||||
y += gttr_block_h
|
||||
|
||||
# ── Adjacent systems ──────────────────────────────────────────────────────
|
||||
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
||||
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12), 1.0)
|
||||
y += 6.0
|
||||
var adj: Array = node.get("adjacent_systems", [])
|
||||
if adj.is_empty():
|
||||
draw_string(font, Vector2(x, y), "No gate connections", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
|
||||
else:
|
||||
var adj_names: Array = []
|
||||
for neighbor_id: String in adj:
|
||||
var neighbor: Dictionary = _node_lookup.get(neighbor_id, {})
|
||||
var n_name: String = neighbor.get("proper_name", "")
|
||||
adj_names.append(n_name if not n_name.is_empty() else neighbor_id)
|
||||
var adj_line: String = " · ".join(adj_names)
|
||||
draw_multiline_string(font, Vector2(x, y), adj_line, HORIZONTAL_ALIGNMENT_LEFT,
|
||||
inner_w, 10, 2, COLOR_TEXT_DIM)
|
||||
|
||||
|
||||
func _draw_title() -> void:
|
||||
var font := get_theme_default_font()
|
||||
draw_string(font, Vector2(16, 28), "THE REACH — NAVIGATOR", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, COLOR_TEXT)
|
||||
draw_string(font, Vector2(16, 44), "Concord Assembly Gate Network — %d Systems" % _nodes.size(),
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
|
||||
|
||||
|
||||
func _dot_radius(topology: String) -> float:
|
||||
match topology:
|
||||
"hub": return DOT_RADIUS_HUB
|
||||
"junction": return DOT_RADIUS_JUNCTION
|
||||
"dead_end": return DOT_RADIUS_DEAD_END
|
||||
_: return DOT_RADIUS_DEFAULT
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Input — selection, pan, zoom
|
||||
# =============================================================================
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.pressed:
|
||||
match mb.button_index:
|
||||
MOUSE_BUTTON_LEFT:
|
||||
_handle_click(mb.position)
|
||||
MOUSE_BUTTON_MIDDLE:
|
||||
_is_panning = true
|
||||
_pan_start = mb.position
|
||||
_pan_start_offset = _pan_offset
|
||||
MOUSE_BUTTON_WHEEL_UP:
|
||||
var old_zoom := _zoom
|
||||
_zoom = clampf(_zoom + ZOOM_STEP, ZOOM_MIN, ZOOM_MAX)
|
||||
if _zoom != old_zoom:
|
||||
_dirty = true
|
||||
MOUSE_BUTTON_WHEEL_DOWN:
|
||||
var old_zoom := _zoom
|
||||
_zoom = clampf(_zoom - ZOOM_STEP, ZOOM_MIN, ZOOM_MAX)
|
||||
if _zoom != old_zoom:
|
||||
_dirty = true
|
||||
else:
|
||||
if mb.button_index == MOUSE_BUTTON_MIDDLE:
|
||||
_is_panning = false
|
||||
|
||||
elif event is InputEventMouseMotion:
|
||||
var mm := event as InputEventMouseMotion
|
||||
if _is_panning:
|
||||
_pan_offset = _pan_start_offset + (mm.position - _pan_start)
|
||||
_dirty = true
|
||||
else:
|
||||
_update_hover(mm.position)
|
||||
|
||||
|
||||
## Find the system_id of the nearest node to screen position, or "" if none within HIT_RADIUS.
|
||||
func _find_nearest_system(pos: Vector2) -> String:
|
||||
var sz: Vector2 = get_rect().size
|
||||
var center: Vector2 = sz * MAP_CENTER_FRACTION + _pan_offset
|
||||
var best_dist: float = HIT_RADIUS
|
||||
var best_sid: String = ""
|
||||
for node: Dictionary in _nodes:
|
||||
var sid: String = node.get("system_id", "")
|
||||
if not _node_positions.has(sid):
|
||||
continue
|
||||
var node_pos: Vector2 = center + _node_positions[sid] * _zoom
|
||||
var dist: float = pos.distance_to(node_pos)
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_sid = sid
|
||||
return best_sid
|
||||
|
||||
|
||||
func _handle_click(pos: Vector2) -> void:
|
||||
var nearest := _find_nearest_system(pos)
|
||||
_selected_system = nearest
|
||||
_dirty = true
|
||||
|
||||
|
||||
func _update_hover(pos: Vector2) -> void:
|
||||
var nearest := _find_nearest_system(pos)
|
||||
if nearest != _hovered_system:
|
||||
_hovered_system = nearest
|
||||
_dirty = true
|
||||
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/star_map.gd" id="1_starmap"]
|
||||
|
||||
; #674: Star map insert module — concentric hop-ring view of the Settled Reach gate network.
|
||||
; Sector-colored, interactive selection, pan/zoom. Accessible from the insert UI.
|
||||
; Data source: res://data/star_map_data.json (enriched from star-map.json + systems.db).
|
||||
; Positioned as full-size overlay. Toggle visibility via set_insert_active() or toggle_visible().
|
||||
|
||||
[node name="StarMapRenderer" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 1
|
||||
script = ExtResource("1_starmap")
|
||||
+11
-1
@@ -604,4 +604,14 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
|
||||
---
|
||||
|
||||
*45 decisions. Last updated: 2026-03-24 (D-104, D-105, D-107 supersession notes added; D-167 corridor cultural system filed)*
|
||||
### D-168: Iserlohn (GJ-532c) planet name — keep, real-city origin documented
|
||||
- **Date:** 2026-04-05
|
||||
- **Decision:** The planet name "Iserlohn" (GJ-532c, in the Kettenschmied system) is **retained**. The name derives from Iserlohn, a real industrial city in the Sauerland region of North Rhine-Westphalia, Germany — historically a centre of wire-drawing, chain manufacturing, and metal fabrication. This origin is directly coherent with Kettenschmied's identity as a west_reach German-heritage metalworking community. The real-city reading is the intended and primary reading.
|
||||
- **IP note:** Iserlohn Fortress is an iconic location in *Legend of the Galactic Heroes* (Ginga Eiyuu Densetsu). The name collision is acknowledged. The decision to retain is deliberate: the real-world German city predates the anime by centuries; real geographic names are not protectable; and the concept diverges fundamentally — a cold marginal mining/fabrication planet (1,000 population, component fabrication, asteroid mining) is the structural opposite of a strategic military megastructure. No reader can point at GJ-532c and say "that's the LoGH fortress" — the contexts share only a name.
|
||||
- **Preferred alternative on record (if future team prefers a rename):** **Altena** — a Sauerland city at the confluence of Lenne and Volme rivers, historically the actual birthplace of German wire-drawing and chain manufacture. The Altena castle became the site of the first industrial wire-drawing operation in the German states. Altena has no known SF franchise association and carries stronger historical precision for chain/cable fabrication. It would require an atlas rename of `GJ532c` and corresponding wiki edits.
|
||||
- **Raised by:** Miri (Sprint 31, ticket #773)
|
||||
- **Dissent:** None.
|
||||
|
||||
---
|
||||
|
||||
*46 decisions. Last updated: 2026-04-05 (D-168 Iserlohn IP evaluation filed)*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"system_id": "GJ 1111",
|
||||
"proper_name": "Zenzele",
|
||||
"star_type": "unusual",
|
||||
"star_type": "M",
|
||||
"spectral_class": "M6.5",
|
||||
"wiki_data": {
|
||||
"habitable_count": 1,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"system_id": "GJ 3943",
|
||||
"proper_name": "GJ 3943",
|
||||
"star_type": "K+M",
|
||||
"proper_name": "Brandpunt",
|
||||
"star_type": "binary",
|
||||
"spectral_class": "K5V+M3V",
|
||||
"wiki_data": {
|
||||
"habitable_count": 1,
|
||||
"inhabited_count": 0,
|
||||
"inhabited_count": 1,
|
||||
"has_gas_giant": false,
|
||||
"has_asteroid_belt": true,
|
||||
"has_horizon_station": true,
|
||||
@@ -14,12 +14,12 @@
|
||||
"bodies": [
|
||||
{
|
||||
"body_id": "GJ3943b",
|
||||
"proper_name": null,
|
||||
"proper_name": "Skuiling",
|
||||
"body_type": "planet",
|
||||
"orbit_index": 1,
|
||||
"parent_body_id": null,
|
||||
"inhabited": false,
|
||||
"population": null,
|
||||
"inhabited": true,
|
||||
"population": 1800,
|
||||
"mass_class": "terrestrial",
|
||||
"surface_gravity": 0.62,
|
||||
"orbital_period_days": 130.0,
|
||||
@@ -27,10 +27,10 @@
|
||||
"atmosphere": "thin",
|
||||
"biome_summary": "cold_arid",
|
||||
"hydrosphere": "ice",
|
||||
"economic_role": null,
|
||||
"settlement_pattern": null,
|
||||
"industrial_corridor": null,
|
||||
"notes": "inner HZ marginal; thin atmosphere, low gravity, polar ice deposits; within parameters for emergency habitation only; not recommended for permanent settlement; unsettled"
|
||||
"economic_role": "research",
|
||||
"settlement_pattern": "urban_concentrated",
|
||||
"industrial_corridor": "research_export",
|
||||
"notes": "polar ice extraction provides water supply and habitat foundation; pressurized dome settlements anchored to ice-extraction framework; Agricultural Spectrum Research Compact facilities; primary K5V+M3V binary oscillation testing environment; 1,800 permanent residents plus rotating research cohort from member communities"
|
||||
},
|
||||
{
|
||||
"body_id": "GJ3943-belt",
|
||||
@@ -176,14 +176,14 @@
|
||||
"stations": [
|
||||
{
|
||||
"station_id": "GJ3943-oort-S1",
|
||||
"proper_name": "GJ 3943 Horizon",
|
||||
"proper_name": "Brandpunt Horizon",
|
||||
"orbits_body_id": "GJ3943-oort",
|
||||
"station_type": "horizon",
|
||||
"population": 0,
|
||||
"population": 120,
|
||||
"economic_role": "transit",
|
||||
"docking_class": "major",
|
||||
"has_gate_infrastructure": true,
|
||||
"notes": "horizon station — 2 apertures; through_route; connects Pedra Seca and GJ 546; unsettled gap in corridor; k-m transitional binary (shifting light registers as color oscillation on viewport); automated; Gate Corporation biennial maintenance"
|
||||
"notes": "horizon station — 2 apertures; through_route; connects Pedra Seca and Eerste Wacht; wave_5 settlement; Agricultural Spectrum Research Compact operations; Commission research contract; researcher rotation transit; K5V+M3V binary (shifting light registers as color oscillation on viewport)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"system_id": "GJ 4056",
|
||||
"proper_name": "GJ 4056",
|
||||
"star_type": "unusual",
|
||||
"spectral_class": "g",
|
||||
"star_type": "M",
|
||||
"spectral_class": "M4V",
|
||||
"wiki_data": {
|
||||
"habitable_count": 0,
|
||||
"inhabited_count": 0,
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
"surface_gravity": 0.89,
|
||||
"orbital_period_days": 200.0,
|
||||
"rotation_period_hours": 24.8,
|
||||
"atmosphere": "breathable",
|
||||
"atmosphere": "standard",
|
||||
"biome_summary": "temperate",
|
||||
"hydrosphere": "rivers-lakes",
|
||||
"hydrosphere": "liquid_water",
|
||||
"economic_role": "agricultural",
|
||||
"settlement_pattern": "distributed-rural",
|
||||
"industrial_corridor": "west_reach",
|
||||
"settlement_pattern": "dispersed",
|
||||
"industrial_corridor": "agricultural_export",
|
||||
"notes": "primary inhabited world; K2V habitable zone; genuine agricultural potential found by founding pastoral cooperative; temperate conditions, good soil, river-valley farmland; Wave 3 settlement ~300 years old; population peaked ~80 years ago and in slow consistent decline as young adults leave for inner-corridor systems offering more economic variety; self-governing council of landholders (reformed ~150 years ago to include non-agricultural members); no Assembly or Compact presence; Gate Corporation maintenance rotation is extent of institutional contact"
|
||||
},
|
||||
{
|
||||
@@ -165,7 +165,7 @@
|
||||
"orbital_period_days": 27.8,
|
||||
"rotation_period_hours": 667.2,
|
||||
"atmosphere": "none",
|
||||
"biome_summary": "ice",
|
||||
"biome_summary": "frozen",
|
||||
"hydrosphere": null,
|
||||
"economic_role": null,
|
||||
"settlement_pattern": null,
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"economic_role": "agricultural",
|
||||
"settlement_pattern": "dispersed",
|
||||
"industrial_corridor": "agricultural_export",
|
||||
"notes": "primary inhabited world; wave_1 German heritage ('edge'); 2-aperture through_route; F2III bright giant — hot equator, settlement in polar and temperate zones; open-field agriculture with substantial surplus; light manufacturing (four centuries institutional investment); Council of Rand governance (nine seats, staggered terms, founding charter continuity); Compact of Westphalia founding member and treaty drafter; five centuries continuous self-governance; oldest governing body in outer west"
|
||||
"notes": "primary inhabited world; wave_1 German heritage ('edge'); 2-aperture through_route; F2III bright giant — significantly more UV-intense than a main-sequence F; equatorial zones inhospitable without shielding, settlement concentrated in polar and high-latitude temperate regions; star is post-main-sequence and progressing toward giant phase on geological timescales, but current habitability is stable for millions of years; open-field agriculture with substantial surplus; light manufacturing (four centuries institutional investment); Council of Rand governance (nine seats, staggered terms, founding charter continuity); Compact of Westphalia founding member and treaty drafter; five centuries continuous self-governance; oldest governing body in outer west"
|
||||
},
|
||||
{
|
||||
"body_id": "GJ601A-belt",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"system_id": "GJ 707",
|
||||
"proper_name": "Dois Sóis",
|
||||
"star_type": "unusual",
|
||||
"spectral_class": "binary",
|
||||
"star_type": "binary",
|
||||
"spectral_class": "K4V+K8V",
|
||||
"wiki_data": {
|
||||
"habitable_count": 1,
|
||||
"inhabited_count": 1,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Cultural Stereotyping Audit — The Settled Reach Wiki
|
||||
**Created:** 2026-04-05
|
||||
**Sprint:** 31
|
||||
**Ticket:** #766
|
||||
**Scope:** 229 wiki entries across 5 sectors (301 total systems; ~72 unnamed)
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
This audit examines the cultural representation patterns in the Settled Reach wiki entries. The concern: the wiki's founding-culture palette is heavily weighted toward Portuguese, Afrikaans, German, Zulu, and Dutch community archetypes, each tending to reproduce a narrow set of social patterns. Systems should reflect the full range of what a culture can do — not just its stereotype.
|
||||
|
||||
**Stereotyping definition:** A cultural group is "stereotyped" when all or nearly all of its systems in the wiki confirm the same narrow cultural pattern. A "subversion" is a system with that cultural heritage that does something the stereotype does not predict.
|
||||
|
||||
**Sprint 30 baseline:** Afrikaans flagged as worst at ~15:1 confirming:subverting ratio, with Brandpunt (GJ-3943) added as first deliberate subversion.
|
||||
|
||||
---
|
||||
|
||||
## Top 3 Most Imbalanced Groups — Sprint 31 Findings
|
||||
|
||||
### 1. Afrikaans / Dutch (West/South Reach)
|
||||
**Estimated ratio at Sprint 31 start:** ~15:1
|
||||
|
||||
**Stereotyping pattern:** Frontier farming cooperatives with conservative self-sufficient governance. Dutch/Afrikaner family networks. Practical, independent, pre-industrial in character. Relationship to land and labor as primary identity.
|
||||
|
||||
**Named Afrikaans/Dutch systems (partial):**
|
||||
- Traagwater, Breëvlei, Wagtoring, Groenland, Kaapse Baai, Stilwater, Bestevaer, Schuilhoek, Dokkum, Vuurkloof
|
||||
- Deep frontier: Pedra Seca, Droëland, Eerste Wacht, Skuilplek, Ouplaas (agricultural farming communities)
|
||||
|
||||
**Existing subversions:**
|
||||
- Brandpunt (GJ-3943) — agricultural research collective (Sprint 30)
|
||||
- Kaapse Baai (GJ-213) — Cape Malay spice culture, cosmopolitan commercial identity
|
||||
- Wagtoring (GJ-410) — secret military/surveillance outpost, not farming
|
||||
|
||||
**Sprint 31 additions:**
|
||||
- **Breëvlei (GJ-661B)** — enriched: ecological research community organized around pre-settlement marsh stakes; farming is the economic substrate but xenobiology is the community's driving purpose
|
||||
- **Bestevaer (GJ-103)** — enriched: Dutch maritime culture organized around an unexplained deep-ocean restricted zone ("De Grens"); marine engineering program covertly building capacity to investigate
|
||||
|
||||
**Remaining ratio estimate:** ~12:5 (improving; further work needed)
|
||||
|
||||
---
|
||||
|
||||
### 2. German / West Reach
|
||||
**Estimated ratio at Sprint 31 start:** ~20:0 (no identified subversions)
|
||||
|
||||
**Stereotyping pattern:** Industrial/manufacturing cooperatives, mining operations, agricultural systems. Practical work ethic, guild-structure governance, Compact of Westphalia affiliation. Quality craftsmanship as practical output, not aesthetic pursuit.
|
||||
|
||||
**Named German systems (partial):**
|
||||
- Kettenschmied, Hansestadt, Neustadt, Altmark, Marktfeld, Bergtor, Freiholt, Steinfeld, Lichtung, Haltefenn, Lichtbrücke, Knotenpunkt, Weitblick, Brückenau, Grenzstein, Ostmark, Eisfeld, Dunkelholz, Gammelstad, Sonnwend, Yttermark, Echternach, Langemark
|
||||
|
||||
**Existing subversions:** None confirmed. (Seiðrmaðr is Scandinavian/Norse, not German; already subversive via tech company.)
|
||||
|
||||
**Sprint 31 additions:**
|
||||
- **Lichtung (GJ-356A)** — full rewrite: artists' colony founded by a cultural collective who came specifically for the extraordinary F-type light environment; no industrial or agricultural focus; exports art, residencies, light-recordings
|
||||
- **Haltefenn (GJ-1248)** — enriched: 400-year-old bog-fuel community whose craft tradition (Moorlesen — peat-layer reading as artistic practice) has become economically significant; inner-orbit antiquarian collectors now represent a larger revenue stream than peat fuel
|
||||
|
||||
**Remaining ratio estimate:** ~21:2 (still heavily imbalanced; requires continued work)
|
||||
|
||||
---
|
||||
|
||||
### 3. Portuguese / South Reach
|
||||
**Estimated ratio at Sprint 31 start:** ~14:1 (estimated)
|
||||
|
||||
**Stereotyping pattern:** Agricultural and fishing communities. Extended family/syndic networks. Relational governance by personal trust rather than institutional procedure. Warm-climate cultivation, emotional expressiveness, saudade-inflected place names.
|
||||
|
||||
**Named Portuguese/Iberian/South_reach systems (partial):**
|
||||
- Caparica, Carvalhais, Confluência, Ribeiro's Star, Ribeirão, Nascente, Espinho, Quilombo, Velha Guarda, Aguas Bravas, Rionegro, Fragua, Puerto Último, Mirante, Roça Nova, Morales' End, Okahandja
|
||||
|
||||
**Existing subversions (underrecognized):**
|
||||
- Espinho (GJ-1075) — Portuguese-named but NOT agricultural; stellar research community, CME observatory, science publishing (clearly subversive of the farming stereotype but not flagged)
|
||||
- Quilombo (GJ-423B) — Afro-Brazilian self-governance tradition; explicitly political, non-familial governance model; "we do not ask for permission to exist"
|
||||
- Fragua (GJ-567) — Spanish heritage but industrial adaptation workshop, not farming
|
||||
- Puerto Último (GJ-197) — Spanish agricultural community facing transformation through new connectivity; complex identity negotiation
|
||||
|
||||
**Sprint 31 additions:**
|
||||
- **Espinho (GJ-1075)** — enriched: made the science/research identity and the departure from south_reach farming stereotype explicit in the text; the community's distinctiveness from neighboring Portuguese systems is now on the page
|
||||
- **Nascente (GJ-2097)** — enriched: the Irmandade do Alcance's four centuries of transit record-keeping reframed as a commercial dynasty; the mutual aid society is a cover for an organization that has accumulated trade intelligence across the entire south_reach corridor
|
||||
|
||||
**Remaining ratio estimate:** ~10:6 (better balanced; Espinho and Quilombo already present as subversions)
|
||||
|
||||
---
|
||||
|
||||
## Other Cultural Groups — Representation Assessment
|
||||
|
||||
### Underrepresented groups (ticket #766 scope)
|
||||
|
||||
The following cultural groups appear in very few or no wiki systems, requiring wiki-level authoring before corridor balance can improve:
|
||||
|
||||
| Group | Current presence | Status |
|
||||
|-------|-----------------|--------|
|
||||
| Italian | 1 (Nova Roma — named but culturally Germanic industrial) | Near-zero presence |
|
||||
| Greek | 0 confirmed | Absent |
|
||||
| Maghreb / North African | 0 confirmed | Absent |
|
||||
| Caribbean | 0 confirmed | Absent |
|
||||
| Pacific Island | 1-2 (Rekohu = Māori name; Mabuhay/Dagat = Filipino) | Minimal |
|
||||
| Indigenous American | 1 (Athabasca = Canadian Indigenous toponym) | Minimal |
|
||||
| West African (non-Igbo) | 2-3 (Kumasi, Kampala Gate, Enugu) | Thin |
|
||||
| Middle Eastern / Arab | 1 (Dalim = Arabic/Persian for moon) | Minimal |
|
||||
| Central European (Polish, Czech) | 2-3 (Kraków Junction, Nová Tržnice, Nowa Huta) | Thin |
|
||||
|
||||
These groups require new named system proposals and wiki entries, which is beyond the Sprint 31 scope but should feed into Sprint 32+ work.
|
||||
|
||||
### Well-represented groups (may not need more subversions before balance in underrepresented groups is addressed)
|
||||
- East Asian (Chinese, Korean, Japanese, Vietnamese): Good spread, multiple subversions visible
|
||||
- South Asian (Indian, Sikh, Bangladeshi): Present at several hops, growing
|
||||
- Commonwealth (British/Australian/Canadian/South African): Strong north_reach presence
|
||||
- Scandinavian: West reach, good density; Seiðrmaðr is existing subversion
|
||||
|
||||
---
|
||||
|
||||
## Recommendations for Follow-up Work
|
||||
|
||||
### Immediate (Sprint 32)
|
||||
1. **Italian representation** — The south_reach and core systems have essentially zero Italian cultural presence despite the demographic plausibility of Italian settlement. Nova Roma's name is Italian but the cultural subtext is Germanic. Recommend: author 2-3 Italian-heritage systems. Suggested archetypes: artistic dynasty (fashion/design house operating across the Reach), engineering family that rivals MVG in specific niche, culinary culture built around terraformed viticulture.
|
||||
|
||||
2. **Greek representation** — Completely absent. Plausible: east_reach academic or mathematical community; astronomical heritage community (Greeks have deep historical astronomy associations); merchant trading family at a through_route junction.
|
||||
|
||||
3. **Maghreb/North African** — Completely absent. Plausible: Trans-Saharan trading tradition adapted to deep frontier transit; Berber or Amazigh mountain-culture heritage on a high-altitude world; Islamic academic institution.
|
||||
|
||||
### Medium-term (Sprint 32-33)
|
||||
- Caribbean representation (Afro-Caribbean, Indo-Caribbean)
|
||||
- Pacific Island representation (Māori, Tongan, Samoan, Hawaiian)
|
||||
- Central American and Andean (distinct from Portuguese/Spanish South_reach pattern)
|
||||
|
||||
### Corridor rule reminder
|
||||
**Corridors are tendencies, not borders.** Every direction needs cross-cultural names. An Italian or Greek community in the west_reach is not an anomaly — it is the expected texture of a Reach where people followed economic opportunity rather than cultural clustering instructions. New names in underrepresented groups should appear across multiple corridors, not concentrated in a single area.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 31 Deliverables Summary
|
||||
|
||||
| System | GJ ID | Cultural Group | Subversion Type | Action |
|
||||
|--------|-------|----------------|-----------------|--------|
|
||||
| Breëvlei | GJ-661B | Afrikaans | Scientific/xenobiology research | Enriched |
|
||||
| Bestevaer | GJ-103 | Dutch/Afrikaans | Maritime mystery culture | Enriched |
|
||||
| Lichtung | GJ-356A | German | Artists' colony | Full rewrite |
|
||||
| Haltefenn | GJ-1248 | German | Craft/artistic tradition as primary identity | Enriched |
|
||||
| Espinho | GJ-1075 | Portuguese | Science research hub (made explicit) | Enriched |
|
||||
| Nascente | GJ-2097 | Portuguese | Commercial dynasty via transit records | Enriched |
|
||||
|
||||
D-168 filed: Iserlohn (GJ-532c) planet name IP evaluation — retained, real-city origin documented.
|
||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
# Sprint 31: Bedrock — CI Tasks
|
||||
|
||||
**Goal:** Complete Phase 1 wiki content (cultural sweep, GTTR depth, star map popups) and refactor accumulated complexity in atlas.rs, main.gd, and Python tooling.
|
||||
|
||||
**Branch:** `sprint-31/ci`
|
||||
**Agents:** Justine (build/deploy), Hoshe (QA)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #777 | Add ruff linting + shared DB path module for Python scripts | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/process.md` — development process decisions
|
||||
|
||||
## Notes
|
||||
|
||||
### #777 ruff linting + shared DB path module (refactor)
|
||||
Python scripts in `tooling/db/` lack linting and share duplicated patterns.
|
||||
|
||||
**Deliver:**
|
||||
1. **pyproject.toml** at repo root with ruff config (line length, target Python version, rule selection)
|
||||
2. **Shared module** `tooling/db/common.py` extracting:
|
||||
- `resolve_db_path()` — uses `PROJECT_DB_PATH` env var with parent-dir fallback
|
||||
- `load_config()` — reads `tooling/db/config.json`
|
||||
- `get_connection()` — returns WAL-mode SQLite connection
|
||||
3. **Update all consumers** — `sqlite_connector.py`, `ticket`, `sprint`, `decisions_sync.py` to import from `common.py` instead of duplicating
|
||||
4. **Pre-push hook integration** — add `ruff check tooling/` to the pre-push hook alongside GDScript and Rust checks
|
||||
5. **Makefile target** — `make lint-python` running ruff
|
||||
|
||||
**Key files:**
|
||||
- `tooling/db/sqlite_connector.py` — current DB path resolution
|
||||
- `tooling/db/ticket` — duplicated resolve pattern
|
||||
- `tooling/db/sprint` — duplicated resolve pattern
|
||||
- `tooling/db/decisions_sync.py` — duplicated resolve pattern
|
||||
- `tooling/db-backup` — bash equivalent
|
||||
|
||||
**Verify:** `ruff check tooling/` passes. All DB CLI commands still work
|
||||
(`tooling/db/ticket count`, `tooling/db/sprint status`).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#777 (ruff + shared module) → standalone
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "chore(ci): Sprint 31 — ruff linting + shared DB module" \
|
||||
--description "body" --base main --head sprint-31/ci
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user