Compare commits
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"autoMemoryDirectory": "/home/jeroenschweitzer/Projects/settled-reach/.memory",
|
"autoMemoryDirectory": "/home/jeroenschweitzer/Projects/settled-reach/.memory",
|
||||||
"env": {
|
"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",
|
"teammateMode": "in-process",
|
||||||
"permissions": {
|
"permissions": {
|
||||||
@@ -63,9 +64,10 @@
|
|||||||
"Bash(list *)",
|
"Bash(list *)",
|
||||||
"Bash(tree *)",
|
"Bash(tree *)",
|
||||||
"Bash(sed -n *)",
|
"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(git-commit)",
|
||||||
"Skill(worktree-update)",
|
|
||||||
"Skill(sprint-start)",
|
"Skill(sprint-start)",
|
||||||
"Skill(sprint-plan)",
|
"Skill(sprint-plan)",
|
||||||
"Skill(pr-push)",
|
"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."
|
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 —
|
Before pushing, verify the game actually runs. This is non-negotiable —
|
||||||
Sprint 28 proved that code review without runtime testing misses critical
|
Sprint 28 proved that code review without runtime testing misses critical
|
||||||
|
|||||||
@@ -16,16 +16,15 @@ on the branch type. All reviewers must approve for a clean review.
|
|||||||
|
|
||||||
## Workflow
|
## 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
|
```bash
|
||||||
git branch --show-current
|
git branch --show-current
|
||||||
```
|
```
|
||||||
|
|
||||||
If the current branch is **not `main`**, stop immediately and tell the user:
|
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."
|
"PR reviews must be run from the `main` branch."
|
||||||
Do NOT proceed with the review. Do NOT work around this by reading files
|
Do NOT proceed with the review.
|
||||||
from another worktree — the review agent itself must be running in main.
|
|
||||||
Stop and wait for the user to invoke `/pr-review` from main.
|
Stop and wait for the user to invoke `/pr-review` from main.
|
||||||
|
|
||||||
### 0b. Verify runtime smoke test was performed
|
### 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
|
If script errors appear in the branch diff files, flag them immediately
|
||||||
before spawning reviewers — no point reviewing code that doesn't parse.
|
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
|
### 1. Determine the branch to review
|
||||||
|
|
||||||
If the user provided a branch name as argument, use it. Otherwise list open
|
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
|
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||||
raw diff to reviewers — cleaner context, better reviews.
|
raw diff to reviewers — cleaner context, better reviews.
|
||||||
|
|
||||||
**IMPORTANT — use team directory paths for ALL agents.** Each team branch
|
**Reviewer agents read source files via `git show`.** Sprint branches
|
||||||
is checked out in its own directory at:
|
use the naming pattern `sprint-{N}/{team}`. To read a file from the
|
||||||
|
branch being reviewed:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
/var/mnt/data/projects/settled-reach/<branch>/
|
git show origin/<branch>:<path>
|
||||||
```
|
```
|
||||||
|
|
||||||
For example, the `copy` team directory is at:
|
For example:
|
||||||
```
|
```bash
|
||||||
/var/mnt/data/projects/settled-reach/copy/content/dialogue/...
|
git show origin/sprint-31/server:server/src/bin/atlas.rs
|
||||||
```
|
```
|
||||||
|
|
||||||
**All reviewer agents** (regardless of Bash access) should read source files
|
If the sprint branch has an active worktree (under `.sprint/`), reviewers
|
||||||
from the team directory using the Read tool. This is more reliable than
|
can also use the Read tool with the worktree path. But `git show` is
|
||||||
`git show origin/<branch>:<path>` because:
|
the reliable default — it works whether or not a worktree exists.
|
||||||
- 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
|
|
||||||
|
|
||||||
When constructing reviewer prompts, tell agents to read files from the
|
Also tell agents to read relevant `decisions/*.md` files for context.
|
||||||
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).
|
|
||||||
|
|
||||||
### 4. Spawn reviewers in parallel
|
### 4. Spawn reviewers in parallel
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,12 @@
|
|||||||
|
|
||||||
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||||
|
|
||||||
**All reviewer agents read from team directories.** Each team branch is
|
**Reviewer agents read source files via `git show` or from sprint
|
||||||
checked out in its own directory at:
|
worktrees.** Sprint branches use `sprint-{N}/{team}` naming. Include
|
||||||
`/var/mnt/data/projects/settled-reach/<branch>/`
|
the branch name and a list of changed files in every prompt. The
|
||||||
|
default approach is `git show origin/<branch>:<path>`. If an active
|
||||||
Tell every reviewer agent to read source files from the team directory
|
worktree exists under `.sprint/`, agents can also use the Read tool
|
||||||
using the Read tool. Include the directory path and a list of changed
|
with the worktree path.
|
||||||
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.
|
|
||||||
|
|
||||||
## Code reviews (`server`, `client`, `ci`)
|
## 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
|
Read the template at `references/briefing-template.md` in this skill directory
|
||||||
for the exact file structure.
|
for the exact file structure.
|
||||||
|
|
||||||
**IMPORTANT — relative paths only:** Each team works in its own directory
|
**Relative paths only:** All file paths in briefings must be relative to
|
||||||
containing the full repo (`server/`, `client/`, `docs/`, etc.). All file
|
the repo root. Example: `server/src/bridge/types.rs`, not absolute paths.
|
||||||
paths in briefings must be relative to the working directory. Example:
|
Each sprint branch (`sprint-{N}/{team}`) contains the full repo.
|
||||||
`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.
|
|
||||||
|
|
||||||
Key requirements per file:
|
Key requirements per file:
|
||||||
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
|
- **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>
|
**Goal:** <One-sentence sprint goal, shared across all teams>
|
||||||
|
|
||||||
**Branch:** `<team>`
|
**Branch:** `sprint-{N}/<team>`
|
||||||
**Agents:** <Agent names and roles>
|
**Agents:** <Agent names and roles>
|
||||||
|
|
||||||
## Carry-over from Sprint N-1
|
## 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):
|
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
|
\```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 |
|
| Team | Branch | Agents | Scope |
|
||||||
|------|--------|--------|-------|
|
|------|--------|--------|-------|
|
||||||
| server | `server` | Dudley (simulation), Oscar (networking) | Rust/bevy_ecs simulation |
|
| server | `sprint-{N}/server` | Dudley (simulation), Oscar (networking) | server/, Rust/bevy_ecs simulation |
|
||||||
| client | `client` | Stig (UI), Oscar (networking) | Godot client rendering |
|
| 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 |
|
| joint | both | All implementation agents | Integration, proofs, cross-team schema |
|
||||||
| content | (none) | Mellanie, Paula, Miri, Araminta | Content authoring, no code branch |
|
| 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
|
git branch --show-current
|
||||||
```
|
```
|
||||||
|
|
||||||
Valid team branches: `server`, `client`, `copy`, `audio`, `visual`, `ci`,
|
Sprint branches follow the pattern `sprint-{N}/{team}` (e.g. `sprint-31/server`).
|
||||||
`planning`.
|
Valid team names: `server`, `client`, `copy`, `audio`, `visual`, `ci`, `planning`.
|
||||||
|
|
||||||
If on `main`, follow the **Main branch workflow** below instead of
|
If on `main`, follow the **Main branch workflow** below instead of
|
||||||
the team branch workflow (steps 2–8).
|
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
|
or backlog tickets on the spot. If process changes are agreed, update
|
||||||
the relevant skill files or CLAUDE.md immediately — don't defer them.
|
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
|
#### A2. Bump the version
|
||||||
|
|
||||||
The project version scheme is `v0.1.{sprint_number}`. After closing
|
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
|
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)
|
- Sprint activated (name, ticket count per team)
|
||||||
- Remind the user to switch to a team branch and run `/sprint-start`
|
- Worktrees created and tabs opened
|
||||||
there (or `cd` into the relevant worktree)
|
- 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
|
### 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
|
```bash
|
||||||
git fetch --all
|
git fetch --all
|
||||||
git merge origin/main --no-edit
|
git merge origin/main --no-edit
|
||||||
@@ -329,14 +354,14 @@ Task(
|
|||||||
team_name: "sprint-{N}-{team}",
|
team_name: "sprint-{N}-{team}",
|
||||||
name: "{name_lowercase}",
|
name: "{name_lowercase}",
|
||||||
prompt: "You are on the {team} team for Sprint {N}.
|
prompt: "You are on the {team} team for Sprint {N}.
|
||||||
Branch: `{team}`
|
Branch: `sprint-{N}/{team}`
|
||||||
|
|
||||||
RULES (NON-NEGOTIABLE):
|
RULES (NON-NEGOTIABLE):
|
||||||
|
|
||||||
0. TEAM BOUNDARY: Your team is `{team}` ($WORKTREE_TEAM). Stay
|
0. TEAM SCOPE: Your team is `{team}` on branch `sprint-{N}/{team}`.
|
||||||
within the current working directory. Do NOT navigate to
|
You may modify files in: {team_scope_dirs}
|
||||||
parent or sibling directories. Do NOT follow .git pointers
|
You may read (but not modify): docs/, decisions/, wiki/, .claude/
|
||||||
to other directories.
|
Do NOT modify files belonging to other teams.
|
||||||
|
|
||||||
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||||
checkout, branch, stash, tag, etc.). All git operations are
|
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
|
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.
|
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
|
#### 9c. Handle review outcome
|
||||||
|
|
||||||
|
When review feedback arrives (from the user, main session, or PR
|
||||||
|
comments):
|
||||||
|
|
||||||
**If CHANGES_REQUESTED:**
|
**If CHANGES_REQUESTED:**
|
||||||
|
|
||||||
1. Parse the review comment table (from the Gitea PR comment or the
|
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
|
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)
|
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)
|
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
|
else
|
||||||
# New branch or no remote ref — fall through to directory checks
|
# New branch or no remote ref — fall through to directory checks
|
||||||
CLIENT_CHANGED=1
|
CLIENT_CHANGED=1
|
||||||
SERVER_CHANGED=1
|
SERVER_CHANGED=1
|
||||||
|
TOOLING_CHANGED=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- GDScript parse check (headless Godot) ---
|
# --- GDScript parse check (headless Godot) ---
|
||||||
@@ -97,6 +99,20 @@ else
|
|||||||
echo "pre-push: WARNING — cargo not found or server/ missing, skipping Rust lint"
|
echo "pre-push: WARNING — cargo not found or server/ missing, skipping Rust lint"
|
||||||
fi
|
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
|
if [ "$ERRORS" -gt 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "pre-push: $ERRORS check(s) failed. Push aborted."
|
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,32 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [v0.1.30] — 2026-04-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -44,18 +44,17 @@ Development follows a strict cascade. Each phase has a concrete deliverable. **D
|
|||||||
|
|
||||||
### Team boundaries
|
### 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`).
|
- All file paths are relative to the repo root (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.
|
- **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 write auto-memory files for other teams.** If `$WORKTREE_TEAM` is `server`, do not write to memory paths containing `client`, `main`, etc.
|
- **Do NOT modify files outside your team scope** unless the ticket explicitly requires it.
|
||||||
- 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.
|
- **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**.
|
||||||
- **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.
|
- **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).
|
||||||
- **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**.
|
|
||||||
|
|
||||||
### Database
|
### 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
|
### Before starting work
|
||||||
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
|
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
|
||||||
@@ -97,7 +96,7 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
|||||||
### File conventions
|
### File conventions
|
||||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
- 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.
|
- 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
|
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||||
- Briefings: one per agent, updated after decision-producing rounds
|
- Briefings: one per agent, updated after decision-producing rounds
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
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 \
|
decisions-sync decisions-coverage decisions-active decisions-orphan \
|
||||||
db-backup db-install validate-content check-fact-ids setup-hooks \
|
db-backup db-install validate-content check-fact-ids setup-hooks \
|
||||||
audit atlas-verify \
|
audit atlas-verify \
|
||||||
@@ -22,7 +22,8 @@ GODOT_VERSION ?= 4.6
|
|||||||
help:
|
help:
|
||||||
@echo "The Settled Reach — Development Commands"
|
@echo "The Settled Reach — Development Commands"
|
||||||
@echo ""
|
@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 build Build client and server"
|
||||||
@echo " make game Build and run the full game (server + client)"
|
@echo " make game Build and run the full game (server + client)"
|
||||||
@echo " make stop Stop any running server instance"
|
@echo " make stop Stop any running server instance"
|
||||||
@@ -33,7 +34,8 @@ help:
|
|||||||
@echo " make test-ipc-protocol Layer 2: mock IPC protocol tests"
|
@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-integration Layer 3: real subprocess round-trip"
|
||||||
@echo " make test-ipc-benchmark IPC latency benchmark (blocked: #555/#556)"
|
@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 Run full CI pipeline locally"
|
||||||
@echo " make ci-client Run client CI checks"
|
@echo " make ci-client Run client CI checks"
|
||||||
@echo " make ci-server Run server CI checks"
|
@echo " make ci-server Run server CI checks"
|
||||||
@@ -82,7 +84,7 @@ help:
|
|||||||
|
|
||||||
# --- Setup ---
|
# --- 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."
|
@echo "Dev environment ready."
|
||||||
|
|
||||||
setup-rust:
|
setup-rust:
|
||||||
@@ -103,6 +105,11 @@ setup-hooks:
|
|||||||
@git config core.hooksPath .config/hooks
|
@git config core.hooksPath .config/hooks
|
||||||
@echo "Git hooks path set to .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 ---
|
# --- Build ---
|
||||||
|
|
||||||
check-protocol:
|
check-protocol:
|
||||||
@@ -222,7 +229,10 @@ clean-imports:
|
|||||||
|
|
||||||
# --- Lint ---
|
# --- Lint ---
|
||||||
|
|
||||||
lint: lint-server lint-client
|
lint: lint-server lint-client lint-python
|
||||||
|
|
||||||
|
lint-python:
|
||||||
|
ruff check tooling/
|
||||||
|
|
||||||
lint-server:
|
lint-server:
|
||||||
cd server && cargo clippy -- -D warnings
|
cd server && cargo clippy -- -D warnings
|
||||||
|
|||||||
+2193
-301
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).
|
## No-op fallback when audio assets absent (D-038).
|
||||||
## Spatial audio positioning for close-range sounds (D-018).
|
## Spatial audio positioning for close-range sounds (D-018).
|
||||||
|
|
||||||
|
signal dip_changed(profile: String)
|
||||||
|
|
||||||
# --- D-067: Recognition chime asset key ---
|
# --- D-067: Recognition chime asset key ---
|
||||||
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
|
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
|
||||||
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
|
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
|
||||||
@@ -63,6 +65,22 @@ const ZONE_ASSETS: Dictionary = {
|
|||||||
"corridor": "amb_corridor_layer",
|
"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
|
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
|
||||||
var _registry: Dictionary = {}
|
var _registry: Dictionary = {}
|
||||||
|
|
||||||
@@ -83,10 +101,6 @@ var _ambient_players: Dictionary = {}
|
|||||||
var _current_zone_id: String = ""
|
var _current_zone_id: String = ""
|
||||||
var _zone_tweens: Array = []
|
var _zone_tweens: Array = []
|
||||||
|
|
||||||
signal dip_changed(profile: String)
|
|
||||||
|
|
||||||
|
|
||||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_setup_buses()
|
_setup_buses()
|
||||||
@@ -200,21 +214,6 @@ func stop_all_loops() -> void:
|
|||||||
stop_loop(key)
|
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).
|
## 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").
|
## event_type: server RangeCategory::Close event type string (e.g. "Footstep").
|
||||||
## world_tile_pos: server tile coordinates — converted to world pixels internally.
|
## 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.
|
# Used by fog shader to distinguish visual treatment per tile.
|
||||||
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
||||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
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 VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||||
|
|
||||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
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 visibility_texture: ImageTexture
|
||||||
var exploration_texture: ImageTexture
|
var exploration_texture: ImageTexture
|
||||||
var zone_tint_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 _vis_bytes: PackedByteArray
|
||||||
var _exp_bytes: PackedByteArray
|
var _exp_bytes: PackedByteArray
|
||||||
@@ -47,16 +56,6 @@ var _width: int = 1
|
|||||||
var _height: int = 1
|
var _height: int = 1
|
||||||
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
|
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:
|
func _ready() -> void:
|
||||||
_resize(Rect2i(0, 0, 64, 64))
|
_resize(Rect2i(0, 0, 64, 64))
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ var player_position: Vector2 = Vector2.ZERO
|
|||||||
var visible_entities: Array = []
|
var visible_entities: Array = []
|
||||||
var visible_tiles: Array = []
|
var visible_tiles: Array = []
|
||||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
|
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)
|
# v2 fields (D-015, D-031)
|
||||||
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
|
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}]
|
var player_inventory: Array = [] # [{item_id, name, slot}]
|
||||||
|
|
||||||
# v7 fields (#435, D-061/D-062)
|
# 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).
|
# D-064: true while dialogue box is visible or fading out (300ms).
|
||||||
# InputMapper suppresses movement when this is true.
|
# InputMapper suppresses movement when this is true.
|
||||||
@@ -152,8 +154,7 @@ var close_sound_events: Array = []
|
|||||||
# Fallback: client-side accumulation (deprecated, remove when server populates field).
|
# Fallback: client-side accumulation (deprecated, remove when server populates field).
|
||||||
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
||||||
var stationary_ticks: int = 0
|
var stationary_ticks: int = 0
|
||||||
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
|
# DEPRECATED: _prev_player_position moved to SnapshotHandler (client-side accumulation fallback).
|
||||||
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
|
||||||
|
|
||||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||||
# D-020: Read directly from snapshot "zone_id" field.
|
# D-020: Read directly from snapshot "zone_id" field.
|
||||||
@@ -162,276 +163,7 @@ var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previou
|
|||||||
var current_zone_id: String = ""
|
var current_zone_id: String = ""
|
||||||
|
|
||||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||||
current_snapshot = snapshot
|
# 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.
|
||||||
if snapshot.has("tick"):
|
var SH := load("res://scripts/snapshot_handler.gd")
|
||||||
current_tick = snapshot.tick
|
SH.apply(snapshot)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# #718: character_visual_descriptor — restored from server snapshot on save/load.
|
|
||||||
# Server persists the descriptor and includes it in ObserverSnapshot after load.
|
|
||||||
# Only update when field is present (null means no change).
|
|
||||||
if snapshot.has("character_visual_descriptor") and snapshot.character_visual_descriptor is Dictionary:
|
|
||||||
# load() returns a cached script — safe to call per-tick once the resource is in cache.
|
|
||||||
# Cannot use CharacterVisualDescriptor directly: autoloads compile before global class_names
|
|
||||||
# are registered, causing a parse-time "not declared" error.
|
|
||||||
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:
|
|
||||||
character_visual_descriptor = restored
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -30,14 +30,6 @@ enum Action {
|
|||||||
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
|
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.
|
# Minimum milliseconds between movement commands, per stance.
|
||||||
# Tuned so Walk feels like walking, Sprint feels fast but readable.
|
# Tuned so Walk feels like walking, Sprint feels fast but readable.
|
||||||
const MOVE_INTERVAL_MS := {
|
const MOVE_INTERVAL_MS := {
|
||||||
@@ -46,6 +38,14 @@ const MOVE_INTERVAL_MS := {
|
|||||||
"Careful": 600, # ~1.7/sec — deliberate, scanning
|
"Careful": 600, # ~1.7/sec — deliberate, scanning
|
||||||
"Crouch": 800, # 1.25/sec — creeping
|
"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
|
var _last_move_msec: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ extends Node
|
|||||||
|
|
||||||
# -- Power profile ------------------------------------------------------------
|
# -- 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.
|
## High-level power classification. POWER_SAVER reserved for future OS API.
|
||||||
enum PowerProfile {
|
enum PowerProfile {
|
||||||
FULL = 0, # Plugged in (charged, charging, or no battery) — no restrictions
|
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)
|
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.
|
## How often (seconds) to re-poll OS for power state changes.
|
||||||
const POWER_POLL_INTERVAL := 30.0
|
const POWER_POLL_INTERVAL := 30.0
|
||||||
|
|
||||||
@@ -48,6 +39,15 @@ const _POWER_STATE_NO_BATTERY := 2
|
|||||||
const _POWER_STATE_CHARGING := 3
|
const _POWER_STATE_CHARGING := 3
|
||||||
const _POWER_STATE_CHARGED := 4
|
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 -------------------------------------------------------------------
|
# -- Memory -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,37 @@
|
|||||||
extends Node
|
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
|
|
||||||
# Type is TestHarness — untyped to avoid autoload parse-order issue.
|
|
||||||
var harness = 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
|
# Signals
|
||||||
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
||||||
signal snapshot_received(snapshot: Dictionary)
|
signal snapshot_received(snapshot: Dictionary)
|
||||||
signal handshake_complete(protocol_version: int)
|
signal handshake_complete(protocol_version: int)
|
||||||
signal handshake_failed(reason: String)
|
signal handshake_failed(reason: String)
|
||||||
|
|
||||||
func _ready() -> void:
|
# Connection states
|
||||||
if test_mode:
|
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
|
||||||
harness = load("res://scripts/protocol/test_harness.gd").new()
|
|
||||||
print("SimBridge: Running in test mode (dynamic snapshot)")
|
|
||||||
|
|
||||||
|
# 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:
|
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||||
if harness: harness.reset()
|
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:
|
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||||
return harness.snapshot()
|
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||||
|
|
||||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
# Transport layer (non-test mode)
|
||||||
return harness.has_los(from, to)
|
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:
|
var _test_tick: int:
|
||||||
get: return harness.tick if harness else 0
|
get: return harness.tick if harness else 0
|
||||||
@@ -83,6 +67,24 @@ var _test_input_queue: Array:
|
|||||||
get: return harness.input_queue if harness else []
|
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 ------------------------------------------------------
|
# -- Connection lifecycle ------------------------------------------------------
|
||||||
|
|
||||||
# Change connection state and emit signal
|
# Change connection state and emit signal
|
||||||
@@ -144,7 +146,7 @@ func _try_connect() -> void:
|
|||||||
_bridge = null
|
_bridge = null
|
||||||
|
|
||||||
# Poll transport layer every frame (non-test mode only)
|
# 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:
|
if test_mode:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -234,7 +236,8 @@ func _process(delta: float) -> void:
|
|||||||
|
|
||||||
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
|
# 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.
|
# Server blocks waiting for this before entering the tick loop.
|
||||||
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed, GameState.character_archetype, GameState.character_visual_descriptor)
|
var startup_bytes := Protocol.encode_startup_message(
|
||||||
|
GameState.world_seed, GameState.character_archetype, GameState.character_visual_descriptor)
|
||||||
if startup_bytes.size() > 0:
|
if startup_bytes.size() > 0:
|
||||||
var send_err := _bridge.send_message(startup_bytes)
|
var send_err := _bridge.send_message(startup_bytes)
|
||||||
if send_err != OK:
|
if send_err != OK:
|
||||||
|
|||||||
@@ -135,12 +135,13 @@ func reset() -> void:
|
|||||||
static func _warn_empty_ids(conditions: Array, path: String) -> void:
|
static func _warn_empty_ids(conditions: Array, path: String) -> void:
|
||||||
for i in conditions.size():
|
for i in conditions.size():
|
||||||
if conditions[i].get("id", "").is_empty():
|
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 ------------------------------------------------------
|
# -- Condition evaluation ------------------------------------------------------
|
||||||
|
|
||||||
func _evaluate_condition(cond: Dictionary) -> bool:
|
func _evaluate_condition(cond: Dictionary) -> bool: # gdlint:disable=max-returns
|
||||||
match cond.get("condition_type", ""):
|
match cond.get("condition_type", ""):
|
||||||
"player_near":
|
"player_near":
|
||||||
return _eval_player_near(cond)
|
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_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
|
||||||
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
|
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
|
# D-048/D-056: Insert-styled UI color palette
|
||||||
# Used by dialogue box, interaction list, radial menu, and other diegetic insert UI.
|
# 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
|
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.
|
# two columns of text comfortably, leaves world game visible alongside.
|
||||||
const DIALOGUE_MAX_WIDTH: int = 1200
|
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
|
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
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_MIN: float = 0.85 # Alpha pulse floor
|
||||||
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
|
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
|
||||||
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
|
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()
|
||||||
+68
-383
@@ -1,5 +1,15 @@
|
|||||||
extends Node2D
|
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 world_renderer = $World
|
||||||
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
|
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
|
||||||
@onready var camera = $Camera2D
|
@onready var camera = $Camera2D
|
||||||
@@ -26,37 +36,17 @@ extends Node2D
|
|||||||
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
@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)
|
@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:
|
func _ready() -> void:
|
||||||
print("The Settled Reach — client initialized")
|
print("The Settled Reach — client initialized")
|
||||||
|
|
||||||
# #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing.
|
# #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
|
camera.position_smoothing_enabled = false
|
||||||
|
|
||||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||||
SimBridge.connect_to_sim()
|
SimBridge.connect_to_sim()
|
||||||
|
|
||||||
# #257: If returning from main menu "Load Game" selection, defer dispatch until connected.
|
# #257: Deferred load dispatch
|
||||||
# In test mode, connect_to_sim() sets CONNECTED synchronously — dispatch fires immediately.
|
|
||||||
# In live mode, state is CONNECTING — signal handler dispatches once connected.
|
|
||||||
if not GameState.pending_load_path.is_empty():
|
if not GameState.pending_load_path.is_empty():
|
||||||
if loading_screen:
|
if loading_screen:
|
||||||
loading_screen.show_loading()
|
loading_screen.show_loading()
|
||||||
@@ -66,46 +56,52 @@ func _ready() -> void:
|
|||||||
SimBridge.connection_state_changed.connect(_on_sim_connected_for_load)
|
SimBridge.connection_state_changed.connect(_on_sim_connected_for_load)
|
||||||
|
|
||||||
# Camera anchor: snap to player position before the first frame renders.
|
# 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()
|
var first_snapshot: Variant = SimBridge.poll_snapshot()
|
||||||
if first_snapshot != null:
|
if first_snapshot != null:
|
||||||
GameState.apply_snapshot(first_snapshot)
|
GameState.apply_snapshot(first_snapshot)
|
||||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||||
_camera_anchored = true
|
_camera_anchored = true
|
||||||
|
|
||||||
# D-061: Connect dialogue box signals
|
# #775: Initialize extracted components
|
||||||
if dialogue_box:
|
_consumers = SnapshotConsumers.new().init({
|
||||||
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
|
"monologue_display": monologue_display,
|
||||||
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
|
"dialogue_box": dialogue_box,
|
||||||
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
"examine_display": examine_display,
|
||||||
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
"loading_screen": loading_screen,
|
||||||
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
|
"debug_console": debug_console,
|
||||||
# D-020 (#558): Decoupled signals — coordinator routes state changes.
|
"cursor_renderer": cursor_renderer,
|
||||||
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
|
"interaction_list": interaction_list,
|
||||||
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
|
"interaction_prompt": interaction_prompt,
|
||||||
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
|
"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
|
# #496: Print gauntlet session summary on disconnect
|
||||||
if gauntlet_hud:
|
if gauntlet_hud:
|
||||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
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()
|
_router = SnapshotEventRouter.new()
|
||||||
# Always-run: child nodes that update from GameState on every snapshot tick.
|
# Always-run: child nodes that update from GameState on every snapshot tick.
|
||||||
if world_renderer:
|
if world_renderer:
|
||||||
_router.register_always(world_renderer.update_from_state)
|
_router.register_always(world_renderer.update_from_state)
|
||||||
_router.register_always(_propagate_insert_state)
|
_router.register_always(_consumers.propagate_insert_state)
|
||||||
_router.register_always(_update_interaction_list)
|
_router.register_always(_consumers.update_interaction_list)
|
||||||
if inventory_grid:
|
if inventory_grid:
|
||||||
_router.register_always(inventory_grid.update_from_state)
|
_router.register_always(inventory_grid.update_from_state)
|
||||||
if stance_indicator:
|
if stance_indicator:
|
||||||
_router.register_always(stance_indicator.update_from_state)
|
_router.register_always(stance_indicator.update_from_state)
|
||||||
if fog_entities:
|
if fog_entities:
|
||||||
_router.register_always(fog_entities.update_from_state)
|
_router.register_always(fog_entities.update_from_state)
|
||||||
_router.register_always(_play_recognition_chimes)
|
_router.register_always(_consumers.play_recognition_chimes)
|
||||||
_router.register_always(_handle_triangle_crisis_events)
|
_router.register_always(_consumers.handle_triangle_crisis_events)
|
||||||
if gauntlet_hud:
|
if gauntlet_hud:
|
||||||
_router.register_always(gauntlet_hud.update_from_state)
|
_router.register_always(gauntlet_hud.update_from_state)
|
||||||
if checklist_overlay:
|
if checklist_overlay:
|
||||||
@@ -118,27 +114,34 @@ func _ready() -> void:
|
|||||||
_router.register_always(journal_panel.update_from_state)
|
_router.register_always(journal_panel.update_from_state)
|
||||||
if debug_overlay:
|
if debug_overlay:
|
||||||
_router.register_always(debug_overlay.update_from_state)
|
_router.register_always(debug_overlay.update_from_state)
|
||||||
_router.register_always(_play_close_sound_events)
|
_router.register_always(_consumers.play_close_sound_events)
|
||||||
_router.register_always(_update_zone)
|
_router.register_always(_consumers.update_zone)
|
||||||
_router.register_always(_update_listening_focus)
|
_router.register_always(_consumers.update_listening_focus)
|
||||||
_router.register_always(_consume_examine_result)
|
_router.register_always(_consumers.consume_examine_result)
|
||||||
# Keyed: consume methods guarded by specific snapshot fields.
|
# Keyed: consume methods guarded by specific snapshot fields.
|
||||||
_router.register("current_monologue", _consume_monologue)
|
_router.register("current_monologue", _consumers.consume_monologue)
|
||||||
_router.register("current_dialogue", _consume_dialogue)
|
_router.register("current_dialogue", _dialogue.consume_dialogue)
|
||||||
_router.register("conversation_events", _consume_conversation_events)
|
_router.register("conversation_events", _dialogue.consume_conversation_events)
|
||||||
_router.register("conversation_ended", _consume_conversation_ended)
|
_router.register("conversation_ended", _dialogue.consume_conversation_ended)
|
||||||
_router.register("dialogue_response", _consume_dialogue_response)
|
_router.register("dialogue_response", _dialogue.consume_dialogue_response)
|
||||||
_router.register("save_result", _consume_save_result)
|
_router.register("save_result", _consumers.consume_save_result)
|
||||||
_router.register("debug_response", _consume_debug_response)
|
_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:
|
if settings_dialog and debug_console:
|
||||||
settings_dialog.debug_console_toggled.connect(debug_console.set_enabled)
|
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:
|
if debug_console:
|
||||||
debug_console.pause_requested.connect(_on_dialogue_pause_requested)
|
debug_console.pause_requested.connect(_dialogue.on_dialogue_pause_requested)
|
||||||
debug_console.unpause_requested.connect(_on_dialogue_unpause_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:
|
func _process(delta: float) -> void:
|
||||||
@@ -153,20 +156,15 @@ func _process(delta: float) -> void:
|
|||||||
_teleport_transition()
|
_teleport_transition()
|
||||||
|
|
||||||
# Late anchor: live mode — first snapshot arrives during _process.
|
# 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:
|
if not _camera_anchored:
|
||||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||||
_camera_anchored = true
|
_camera_anchored = true
|
||||||
|
|
||||||
# #559: Dispatch snapshot to registered handlers (router pattern).
|
# #559: Dispatch snapshot to registered handlers (router pattern).
|
||||||
# Always-run handlers update child nodes; keyed handlers fire for present fields.
|
|
||||||
_router.dispatch(snapshot)
|
_router.dispatch(snapshot)
|
||||||
|
|
||||||
# Track camera to player (D-015: locked, fixed-north).
|
# Track camera to player (D-015: locked, fixed-north).
|
||||||
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
# #117: Manual exponential smoothing.
|
||||||
# Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame.
|
|
||||||
# Init: camera already snapped in _ready() or late-anchor path above.
|
|
||||||
if _camera_anchored:
|
if _camera_anchored:
|
||||||
var target := GameState.player_position * Constants.TILE_SIZE
|
var target := GameState.player_position * Constants.TILE_SIZE
|
||||||
if _teleport_in_progress:
|
if _teleport_in_progress:
|
||||||
@@ -177,11 +175,9 @@ func _process(delta: float) -> void:
|
|||||||
camera.global_position = camera.global_position.lerp(target, weight)
|
camera.global_position = camera.global_position.lerp(target, weight)
|
||||||
|
|
||||||
# Send queued input to simulation
|
# 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()
|
var inputs = InputMapper.flush_queue()
|
||||||
for input in inputs:
|
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 input.action == InputMapper.Action.BUG_REPORT:
|
||||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||||
bug_report_dialog.start_capture()
|
bug_report_dialog.start_capture()
|
||||||
@@ -190,7 +186,7 @@ func _process(delta: float) -> void:
|
|||||||
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||||
_toggle_journal()
|
_toggle_journal()
|
||||||
continue
|
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:
|
if input.action == InputMapper.Action.LOAD_GAME:
|
||||||
var err := SimBridge.send_input(input)
|
var err := SimBridge.send_input(input)
|
||||||
_pending_record_inputs.append(input)
|
_pending_record_inputs.append(input)
|
||||||
@@ -217,7 +213,6 @@ func _process(delta: float) -> void:
|
|||||||
if target_id < 0 and interaction_prompt:
|
if target_id < 0 and interaction_prompt:
|
||||||
target_id = interaction_prompt.get_interaction_target()
|
target_id = interaction_prompt.get_interaction_target()
|
||||||
verb = interaction_prompt.get_selected_verb()
|
verb = interaction_prompt.get_selected_verb()
|
||||||
# Always send struct form for Interact (#415) — server expects named fields
|
|
||||||
if target_id >= 0:
|
if target_id >= 0:
|
||||||
input["action_data"] = {
|
input["action_data"] = {
|
||||||
"target_entity_id": target_id,
|
"target_entity_id": target_id,
|
||||||
@@ -231,9 +226,7 @@ func _process(delta: float) -> void:
|
|||||||
SimBridge.send_input(input)
|
SimBridge.send_input(input)
|
||||||
_pending_record_inputs.append(input)
|
_pending_record_inputs.append(input)
|
||||||
|
|
||||||
# #507: Record tick data to ring buffer — once per server tick (snapshot arrival).
|
# #507: Record tick data to ring buffer
|
||||||
# Flushes all inputs accumulated since the last snapshot (across multiple display frames),
|
|
||||||
# then clears the accumulator for the next tick.
|
|
||||||
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
|
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
|
||||||
bug_report_dialog.record_tick(
|
bug_report_dialog.record_tick(
|
||||||
GameState.current_tick,
|
GameState.current_tick,
|
||||||
@@ -243,296 +236,13 @@ func _process(delta: float) -> void:
|
|||||||
_pending_record_inputs.clear()
|
_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)
|
|
||||||
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.
|
|
||||||
# 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
|
# #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:
|
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||||
gauntlet_hud.finalize()
|
gauntlet_hud.finalize()
|
||||||
|
|
||||||
|
|
||||||
# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED.
|
# #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:
|
func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||||
if new_state != SimBridge.ConnectionState.CONNECTED:
|
if new_state != SimBridge.ConnectionState.CONNECTED:
|
||||||
return
|
return
|
||||||
@@ -557,19 +267,13 @@ func _dispatch_pending_load() -> void:
|
|||||||
loading_screen.hide_loading(false)
|
loading_screen.hide_loading(false)
|
||||||
|
|
||||||
|
|
||||||
# #501: Detect large position jump indicating a teleport (not normal movement).
|
# #501: Detect large position jump indicating a teleport.
|
||||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
|
||||||
|
|
||||||
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
|
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
|
||||||
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
|
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
|
# #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:
|
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
|
_camera_anchored = true
|
||||||
_teleport_in_progress = true
|
_teleport_in_progress = true
|
||||||
|
|
||||||
@@ -577,12 +281,11 @@ func _teleport_transition() -> void:
|
|||||||
GameState.current_monologue = null
|
GameState.current_monologue = null
|
||||||
GameState.current_dialogue = null
|
GameState.current_dialogue = null
|
||||||
GameState.dialogue_active = false
|
GameState.dialogue_active = false
|
||||||
_known_recognition_ids.clear() # D-067: reset chimes for new room
|
_consumers.clear_recognition_state()
|
||||||
_known_triangle_ids.clear() # #590: reset activation chimes for new room
|
|
||||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||||
dialogue_box.hide_dialogue()
|
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):
|
if _flash_rect and is_instance_valid(_flash_rect):
|
||||||
_flash_rect.queue_free()
|
_flash_rect.queue_free()
|
||||||
_flash_rect = ColorRect.new()
|
_flash_rect = ColorRect.new()
|
||||||
@@ -595,35 +298,17 @@ func _teleport_transition() -> void:
|
|||||||
tween.tween_callback(_flash_rect.queue_free)
|
tween.tween_callback(_flash_rect.queue_free)
|
||||||
|
|
||||||
|
|
||||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
# #264: Toggle journal panel.
|
||||||
# Clears after display (single-consume). Dismiss examine when dialogue opens.
|
|
||||||
func _consume_examine_result() -> void:
|
|
||||||
if GameState.current_examine_result == null or not examine_display:
|
|
||||||
return
|
|
||||||
var result: Dictionary = GameState.current_examine_result
|
|
||||||
# Dismiss existing examine result if dialogue is active (focus priority)
|
|
||||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
|
||||||
if examine_display.has_method("dismiss"):
|
|
||||||
examine_display.dismiss()
|
|
||||||
else:
|
|
||||||
if examine_display.has_method("show_result"):
|
|
||||||
examine_display.show_result(result)
|
|
||||||
GameState.current_examine_result = null
|
|
||||||
|
|
||||||
|
|
||||||
# #264: Toggle journal panel. Called from input handler when J key pressed.
|
|
||||||
func _toggle_journal() -> void:
|
func _toggle_journal() -> void:
|
||||||
if not journal_panel:
|
if not journal_panel:
|
||||||
return
|
return
|
||||||
# Journal and dialogue cannot be open simultaneously (sprint briefing)
|
|
||||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||||
return
|
return
|
||||||
if journal_panel.has_method("toggle"):
|
if journal_panel.has_method("toggle"):
|
||||||
journal_panel.toggle()
|
journal_panel.toggle()
|
||||||
|
|
||||||
|
|
||||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
# #502: Full-screen color flash.
|
||||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
|
||||||
func _screen_flash(color: Color, duration: float) -> void:
|
func _screen_flash(color: Color, duration: float) -> void:
|
||||||
if _flash_rect and is_instance_valid(_flash_rect):
|
if _flash_rect and is_instance_valid(_flash_rect):
|
||||||
_flash_rect.queue_free()
|
_flash_rect.queue_free()
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ func _try_extract_message() -> PackedByteArray:
|
|||||||
if _pending_length > MAX_MESSAGE_SIZE:
|
if _pending_length > MAX_MESSAGE_SIZE:
|
||||||
# Stream is corrupt — we can't find the next valid frame boundary.
|
# Stream is corrupt — we can't find the next valid frame boundary.
|
||||||
# Disconnect rather than silently discarding valid buffered data.
|
# 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
|
_corrupt = true
|
||||||
_pending_length = -1
|
_pending_length = -1
|
||||||
_read_buffer.clear()
|
_read_buffer.clear()
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
# Version check: reject snapshots from incompatible server
|
# Version check: reject snapshots from incompatible server
|
||||||
var version: Variant = raw.get("version")
|
var version: Variant = raw.get("version")
|
||||||
if version != PROTOCOL_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
|
return null
|
||||||
|
|
||||||
var entities: Array[Dictionary] = []
|
var entities: Array[Dictionary] = []
|
||||||
@@ -49,7 +50,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
|||||||
dropped += 1
|
dropped += 1
|
||||||
|
|
||||||
if dropped > 0:
|
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
|
# 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).
|
# in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031).
|
||||||
@@ -484,12 +486,11 @@ static func _decode_verb_option(raw) -> Variant:
|
|||||||
static func _decode_enum_variant(raw) -> Dictionary:
|
static func _decode_enum_variant(raw) -> Dictionary:
|
||||||
if raw is String:
|
if raw is String:
|
||||||
return { "variant": raw, "data": null }
|
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]
|
var variant_name: String = raw.keys()[0]
|
||||||
return { "variant": variant_name, "data": raw[variant_name] }
|
return { "variant": variant_name, "data": raw[variant_name] }
|
||||||
else:
|
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
|
||||||
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
|
return { "variant": "Unknown", "data": raw }
|
||||||
return { "variant": "Unknown", "data": raw }
|
|
||||||
|
|
||||||
|
|
||||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||||
@@ -499,7 +500,8 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
|||||||
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
|
## 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).
|
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
|
||||||
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
|
## 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:
|
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.
|
# Map client lowercase archetype string to server PascalCase enum variant.
|
||||||
# Explicit match prevents unknown strings silently reaching the server as
|
# Explicit match prevents unknown strings silently reaching the server as
|
||||||
# garbage enum values — fail loudly and fall back to "Detective".
|
# garbage enum values — fail loudly and fall back to "Detective".
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ extends RefCounted
|
|||||||
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
|
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
|
||||||
## boundary (no game logic in the production client autoload).
|
## 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 tick: int = 0
|
||||||
var player_pos: Vector2i = Vector2i(10, 10)
|
var player_pos: Vector2i = Vector2i(10, 10)
|
||||||
var facing: String = "North"
|
var facing: String = "North"
|
||||||
@@ -116,9 +130,9 @@ func snapshot() -> Dictionary:
|
|||||||
"npc_entity_id": 2,
|
"npc_entity_id": 2,
|
||||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||||
"options": [
|
"options": [
|
||||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
{"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},
|
{"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},
|
{"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": "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": "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": "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."},
|
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||||
]
|
]
|
||||||
var conv_tick_interval := 5
|
var conv_tick_interval := 5
|
||||||
@@ -259,21 +273,6 @@ func _get_tile_type(x: int, y: int) -> String:
|
|||||||
|
|
||||||
# -- Spatial helpers -----------------------------------------------------------
|
# -- 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:
|
func _is_walkable(pos: Vector2i) -> bool:
|
||||||
return not _WALLS.has(pos)
|
return not _WALLS.has(pos)
|
||||||
|
|
||||||
|
|||||||
@@ -451,7 +451,8 @@ func _load_eyebrows(_desc: CharacterVisualDescriptor) -> void:
|
|||||||
pass
|
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:
|
if _skeleton == null:
|
||||||
return null
|
return null
|
||||||
if not ResourceLoader.exists(path):
|
if not ResourceLoader.exists(path):
|
||||||
@@ -502,14 +503,13 @@ func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE,
|
|||||||
_apply_tinted_shader(mi, tint, mask_tex)
|
_apply_tinted_shader(mi, tint, mask_tex)
|
||||||
inst.queue_free()
|
inst.queue_free()
|
||||||
return null
|
return null
|
||||||
else:
|
# Unskinned — rigid attachment via BoneAttachment3D
|
||||||
# Unskinned — rigid attachment via BoneAttachment3D
|
for mi in meshes:
|
||||||
for mi in meshes:
|
mi.get_parent().remove_child(mi)
|
||||||
mi.get_parent().remove_child(mi)
|
mi.owner = null
|
||||||
mi.owner = null
|
attachment.add_child(mi)
|
||||||
attachment.add_child(mi)
|
_apply_tinted_shader(mi, tint, mask_tex)
|
||||||
_apply_tinted_shader(mi, tint, mask_tex)
|
inst.queue_free()
|
||||||
inst.queue_free()
|
|
||||||
return attachment
|
return attachment
|
||||||
|
|
||||||
|
|
||||||
@@ -629,7 +629,8 @@ func _load_accessories(desc: CharacterVisualDescriptor) -> void:
|
|||||||
# Internal — tinting (hair, accessories, bone-attached assets with tint)
|
# 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:
|
if mi.mesh == null:
|
||||||
return
|
return
|
||||||
for surf in range(mi.mesh.get_surface_count()):
|
for surf in range(mi.mesh.get_surface_count()):
|
||||||
|
|||||||
@@ -5,19 +5,11 @@ extends Node2D
|
|||||||
## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer).
|
## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer).
|
||||||
## Detects entity hover via world-space proximity to visible entities.
|
## Detects entity hover via world-space proximity to visible entities.
|
||||||
|
|
||||||
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
|
signal state_changed(new_state: int)
|
||||||
|
|
||||||
# --- 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 hovered_entity_changed(entity_id: int)
|
signal hovered_entity_changed(entity_id: int)
|
||||||
|
|
||||||
|
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
|
||||||
|
|
||||||
# D-056 colors
|
# D-056 colors
|
||||||
const COLOR_DEFAULT := Color("#c8d0e0")
|
const COLOR_DEFAULT := Color("#c8d0e0")
|
||||||
const COLOR_OBJECT := Color("#8b8ba0")
|
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_HALF := 26.0
|
||||||
const BRACKET_ARM := 8.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 ---
|
# --- Transition state ---
|
||||||
var _target: State = State.DEFAULT
|
var _target: State = State.DEFAULT
|
||||||
var _t: float = 1.0
|
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.
|
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
||||||
# Fast enough for Sprint snappiness, slow enough for Walk to show sliding.
|
# Fast enough for Sprint snappiness, slow enough for Walk to show sliding.
|
||||||
const LERP_SPEED: float = 12.0
|
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_nodes: Dictionary = {} # entity_id -> Node2D
|
||||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
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_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
|
||||||
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
|
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:
|
func _ready() -> void:
|
||||||
print("EntityRenderer: Initialized")
|
print("EntityRenderer: Initialized")
|
||||||
|
|
||||||
@@ -111,7 +110,8 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
|||||||
_entity_facing[entity_id] = direction
|
_entity_facing[entity_id] = direction
|
||||||
var tex := _load_sprite_texture(direction)
|
var tex := _load_sprite_texture(direction)
|
||||||
if tex == null:
|
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
|
entity_node.texture = tex
|
||||||
|
|
||||||
# D-033: self_modulate for relationship tinting; modulate.a is reserved for D-015 dimming.
|
# 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
|
## Architecture: docs/architecture/fog-shader-spec.md
|
||||||
|
|
||||||
signal fog_noise_ready
|
signal fog_noise_ready
|
||||||
|
|
||||||
|
const TILE_SIZE := float(Constants.TILE_SIZE)
|
||||||
|
|
||||||
var _noise_ready: bool = false
|
var _noise_ready: bool = false
|
||||||
|
|
||||||
var _fog_rect: ColorRect
|
var _fog_rect: ColorRect
|
||||||
var _shader_mat: ShaderMaterial
|
var _shader_mat: ShaderMaterial
|
||||||
|
|
||||||
const TILE_SIZE := float(Constants.TILE_SIZE)
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Create the fog overlay ColorRect — transparent fallback so a shader failure
|
# Create the fog overlay ColorRect — transparent fallback so a shader failure
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ extends TileMapLayer
|
|||||||
# (3,0) = object — teal
|
# (3,0) = object — teal
|
||||||
# (4,0) = reset_plate — amber (#502)
|
# (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 TILE_SIZE: int = Constants.TILE_SIZE
|
||||||
const GROUND_FLOOR: int = 0 # Server floor level for ground — filter target in update_tiles()
|
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
|
# Wire-format string to TileType mapping
|
||||||
const TILE_TYPE_MAP: Dictionary = {
|
const TILE_TYPE_MAP: Dictionary = {
|
||||||
"floor": TileType.FLOOR,
|
"floor": TileType.FLOOR,
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ extends Node2D
|
|||||||
# Overhead (Node2D) — z:300 ceiling/upper structure (placeholder)
|
# Overhead (Node2D) — z:300 ceiling/upper structure (placeholder)
|
||||||
# FogOverlay (Node2D) — z:900 fog shader (OUTSIDE FogGroup)
|
# FogOverlay (Node2D) — z:900 fog shader (OUTSIDE FogGroup)
|
||||||
|
|
||||||
|
var _last_tick: int = -1
|
||||||
|
|
||||||
@onready var tile_renderer = $FogGroup/FloorTiles
|
@onready var tile_renderer = $FogGroup/FloorTiles
|
||||||
@onready var fog_renderer = $FogOverlay
|
@onready var fog_renderer = $FogOverlay
|
||||||
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
|
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
|
||||||
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
|
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
|
||||||
|
|
||||||
var _last_tick: int = -1
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
print("WorldRenderer: Initialized (D-049 z-stack)")
|
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.
|
## run before the project's class_name registry is fully populated.
|
||||||
extends SceneTree
|
extends SceneTree
|
||||||
|
|
||||||
var _Msgpack: GDScript
|
var _msgpack: GDScript
|
||||||
var _count := 0
|
var _count := 0
|
||||||
var _errors := 0
|
var _errors := 0
|
||||||
var _output_dir: String
|
var _output_dir: String
|
||||||
@@ -21,7 +21,7 @@ func _init():
|
|||||||
|
|
||||||
|
|
||||||
func _run():
|
func _run():
|
||||||
_Msgpack = load("res://addons/messagepack/messagepack.gd")
|
_msgpack = load("res://addons/messagepack/messagepack.gd")
|
||||||
|
|
||||||
# Resolve repo root from Godot project root (client/).
|
# Resolve repo root from Godot project root (client/).
|
||||||
# Assumes client/ is one level below repo root — validated below.
|
# 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:
|
else:
|
||||||
action = action_name
|
action = action_name
|
||||||
|
|
||||||
var result = _Msgpack.encode({"tick": tick, "action": action})
|
var result = _msgpack.encode({"tick": tick, "action": action})
|
||||||
if result.status != null:
|
if result.status != null:
|
||||||
push_error("Encode failed: %s" % result.status)
|
push_error("Encode failed: %s" % result.status)
|
||||||
return PackedByteArray()
|
return PackedByteArray()
|
||||||
@@ -104,7 +104,7 @@ func _encode_inputs(inputs: Array) -> PackedByteArray:
|
|||||||
action = action_name
|
action = action_name
|
||||||
wire_inputs.append({"tick": input["tick"], "action": action})
|
wire_inputs.append({"tick": input["tick"], "action": action})
|
||||||
|
|
||||||
var result = _Msgpack.encode(wire_inputs)
|
var result = _msgpack.encode(wire_inputs)
|
||||||
if result.status != null:
|
if result.status != null:
|
||||||
push_error("Batch encode failed: %s" % result.status)
|
push_error("Batch encode failed: %s" % result.status)
|
||||||
return PackedByteArray()
|
return PackedByteArray()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
class_name TestAiDialogueSprint26
|
class_name TestAiDialogueSprint26
|
||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
const SETTINGS_DIALOG_SCENE = preload("res://ui/settings_dialog.tscn")
|
||||||
|
|
||||||
var _original_ai_enabled: bool = true
|
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:
|
func test_settings_dialog_exposes_ai_dialogue_label_text_method() -> void:
|
||||||
# settings_dialog needs a testable API — hardcoded UI strings are easy to drift.
|
# 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
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:
|
func test_settings_dialog_ai_dialogue_label_is_correct() -> void:
|
||||||
# D-138: label must be exactly "AI-Enhanced Dialogue" (Jeroen's wording).
|
# 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
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:
|
func test_settings_dialog_toggle_disabled_when_hardware_fails() -> void:
|
||||||
# D-138 §8: RAM < 1.6 GB → feature disabled, toggle greyed out.
|
# D-138 §8: RAM < 1.6 GB → feature disabled, toggle greyed out.
|
||||||
# Player receives message but cannot enable the feature.
|
# 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
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:
|
func test_settings_dialog_toggle_enabled_when_hardware_passes() -> void:
|
||||||
# "pass" → toggle available to interact with.
|
# "pass" → toggle available to interact with.
|
||||||
var scene := load("res://ui/settings_dialog.tscn") as PackedScene
|
var scene := SETTINGS_DIALOG_SCENE
|
||||||
if scene == null:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
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:
|
func test_settings_dialog_toggle_enabled_when_hardware_marginal() -> void:
|
||||||
# D-138 §8: "marginal" → warn but let player proceed. Never force-disable.
|
# 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
return
|
||||||
@@ -439,8 +440,8 @@ func test_hardware_detector_benchmark_cache_path_is_correct() -> void:
|
|||||||
var det := _get_detector()
|
var det := _get_detector()
|
||||||
if det == null:
|
if det == null:
|
||||||
return
|
return
|
||||||
assert_str(det.BENCHMARK_CACHE_PATH).override_failure_message(
|
assert_str(det.benchmark_cache_path).override_failure_message(
|
||||||
"BENCHMARK_CACHE_PATH must be 'user://ai-dialogue-config.json' (D-138 §8)"
|
"benchmark_cache_path must be 'user://ai-dialogue-config.json' (D-138 §8)"
|
||||||
).is_equal("user://ai-dialogue-config.json")
|
).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:
|
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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
return
|
||||||
@@ -779,7 +780,7 @@ func test_settings_dialog_inference_suspended_state_defaults_false() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func test_settings_dialog_set_inference_suspended_true() -> 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
return
|
||||||
@@ -794,7 +795,7 @@ func test_settings_dialog_set_inference_suspended_true() -> void:
|
|||||||
|
|
||||||
func test_settings_dialog_resume_clears_suspended_state() -> void:
|
func test_settings_dialog_resume_clears_suspended_state() -> void:
|
||||||
# D-138 §8: resume when plugged in — suspended state clears.
|
# 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
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 —
|
# Battery suspend auto-pauses inference but must NOT grey the toggle —
|
||||||
# the player can click it to override the suspension.
|
# the player can click it to override the suspension.
|
||||||
# Only hardware "fail" (RAM < 1.6 GB) may disable the toggle.
|
# 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
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:
|
func test_settings_dialog_toggle_enabled_after_resume() -> void:
|
||||||
# After resume (plug-in), toggle must be enabled again.
|
# 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
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:
|
func test_settings_dialog_toggle_disabled_by_hardware_fail_even_when_suspended() -> void:
|
||||||
# Hardware "fail" disables the toggle regardless of battery state.
|
# Hardware "fail" disables the toggle regardless of battery state.
|
||||||
# RAM < 1.6 GB is the only hard disable — battery suspend is not.
|
# 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
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:
|
func test_settings_dialog_warning_label_shown_when_battery_suspended() -> void:
|
||||||
# When inference is battery-suspended, a warning label must be visible
|
# 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).
|
# 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
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:
|
func test_settings_dialog_warning_label_hidden_when_not_suspended() -> void:
|
||||||
# Warning label must not show when plugged in — no battery message needed.
|
# 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:
|
if scene == null:
|
||||||
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
push_warning("TestAiDialogueSprint26: settings_dialog.tscn not found — skipped")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,10 +12,11 @@
|
|||||||
class_name TestAntiTedium
|
class_name TestAntiTedium
|
||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||||
|
|
||||||
var _instance: Node = null
|
|
||||||
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
|
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
|
||||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||||
|
var _instance: Node = null
|
||||||
|
|
||||||
|
|
||||||
func before_test() -> void:
|
func before_test() -> void:
|
||||||
@@ -106,7 +107,7 @@ func _make_bug_report_dialog() -> Control:
|
|||||||
|
|
||||||
func test_bug_report_dialog_exists_in_scene() -> void:
|
func test_bug_report_dialog_exists_in_scene() -> void:
|
||||||
# Verify the BugReportDialog node is present and hidden by default.
|
# Verify the BugReportDialog node is present and hidden by default.
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_bug_report_activates_on_action() -> void:
|
||||||
# Inject BUG_REPORT action directly into the queue and verify main.gd
|
# Inject BUG_REPORT action directly into the queue and verify main.gd
|
||||||
# triggers the dialog. This tests the full main._process() handling path.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_no_gauntlet_ui_visible_in_default_mode() -> void:
|
||||||
# Load main scene — represents non-Gauntlet (default) play mode
|
# Load main scene — represents non-Gauntlet (default) play mode
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
|
||||||
# Simulate several ticks of normal play — gauntlet UI must never appear.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ func before_test() -> void:
|
|||||||
for bus in AudioManager.BUSES:
|
for bus in AudioManager.BUSES:
|
||||||
AudioManager.set_volume(bus, 0.0)
|
AudioManager.set_volume(bus, 0.0)
|
||||||
GameState.stationary_ticks = 0
|
GameState.stationary_ticks = 0
|
||||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||||
|
|
||||||
|
|
||||||
func after_test() -> void:
|
func after_test() -> void:
|
||||||
AudioManager.clear_dip()
|
AudioManager.clear_dip()
|
||||||
GameState.stationary_ticks = 0
|
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,
|
"tick": 1,
|
||||||
"pending_recognitions": [
|
"pending_recognitions": [
|
||||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
{"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)
|
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,
|
"tick": 1,
|
||||||
"pending_recognitions": [
|
"pending_recognitions": [
|
||||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
{"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)
|
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||||
|
|||||||
@@ -11,12 +11,11 @@
|
|||||||
class_name TestBugReportRingBuffer
|
class_name TestBugReportRingBuffer
|
||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
|
||||||
|
|
||||||
# Expected ring buffer capacity per spec.
|
# Expected ring buffer capacity per spec.
|
||||||
const EXPECTED_CAPACITY := 60
|
const EXPECTED_CAPACITY := 60
|
||||||
|
|
||||||
|
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||||
|
|
||||||
|
|
||||||
func after_each() -> void:
|
func after_each() -> void:
|
||||||
# Reset GameState fields mutated by tests to prevent cross-test leakage.
|
# 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_PLAYER_POS := Vector2(10, 10)
|
||||||
const EXPECTED_CAMERA_POS := Vector2(320, 320) # 10 * 32, 10 * 32
|
const EXPECTED_CAMERA_POS := Vector2(320, 320) # 10 * 32, 10 * 32
|
||||||
|
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||||
|
|
||||||
var _instance: Node = null
|
var _instance: Node = null
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ func test_apply_snapshot_sets_player_position() -> void:
|
|||||||
# --- Camera anchor after _ready() ---
|
# --- Camera anchor after _ready() ---
|
||||||
|
|
||||||
func test_camera_position_after_ready() -> void:
|
func test_camera_position_after_ready() -> void:
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -77,7 +78,7 @@ func test_camera_position_after_ready() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func test_camera_anchored_flag_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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -88,7 +89,7 @@ func test_camera_anchored_flag_after_ready() -> void:
|
|||||||
func test_camera_smoothing_off_after_ready() -> void:
|
func test_camera_smoothing_off_after_ready() -> void:
|
||||||
# Camera smoothing must be disabled during init to prevent lerp from (0,0).
|
# 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.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
|
||||||
# #117: Manual lerp approach — Godot's built-in smoothing must stay OFF always.
|
# #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.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -114,7 +115,7 @@ func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
|
|||||||
# --- Camera behavior across frames ---
|
# --- Camera behavior across frames ---
|
||||||
|
|
||||||
func test_camera_tracks_player_after_process() -> void:
|
func test_camera_tracks_player_after_process() -> void:
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -130,7 +131,7 @@ func test_camera_tracks_player_after_process() -> void:
|
|||||||
func test_camera_lerps_toward_player_movement() -> void:
|
func test_camera_lerps_toward_player_movement() -> void:
|
||||||
# #117: With manual lerp, camera moves TOWARD player position (not snapping).
|
# #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.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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.
|
# Second load must produce exactly the same child count — no more, no less.
|
||||||
# _clear() frees and rebuilds; any deviation indicates stale nodes accumulating.
|
# _clear() frees and rebuilds; any deviation indicates stale nodes accumulating.
|
||||||
assert_int(count_after_second).override_failure_message(
|
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)
|
).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"}
|
desc.clothing_slots = {"torso": "coveralls_basic"}
|
||||||
node.load_descriptor(desc)
|
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():
|
if coverage.is_empty():
|
||||||
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
|
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ func test_parse_top_level_quoted_string() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func test_parse_single_condition() -> 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)
|
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||||
assert_that(result.has("conditions")).is_true()
|
assert_that(result.has("conditions")).is_true()
|
||||||
var conditions: Array = result["conditions"]
|
var conditions: Array = result["conditions"]
|
||||||
@@ -70,7 +71,9 @@ func test_parse_single_condition() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func test_parse_multiple_conditions() -> 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 result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||||
var conditions: Array = result["conditions"]
|
var conditions: Array = result["conditions"]
|
||||||
assert_that(conditions.size()).is_equal(2)
|
assert_that(conditions.size()).is_equal(2)
|
||||||
@@ -80,7 +83,8 @@ func test_parse_multiple_conditions() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func test_parse_comments_ignored() -> 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)
|
var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml)
|
||||||
assert_that(result.get("room_id")).is_equal("test")
|
assert_that(result.get("room_id")).is_equal("test")
|
||||||
var conditions: Array = result["conditions"]
|
var conditions: Array = result["conditions"]
|
||||||
|
|||||||
@@ -352,7 +352,6 @@ func test_fog_overlay_z_layer() -> void:
|
|||||||
return
|
return
|
||||||
# This test needs the scene tree to be set up
|
# This test needs the scene tree to be set up
|
||||||
# Verify via scene file inspection rather than runtime
|
# Verify via scene file inspection rather than runtime
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# -- Noise animation cycles (D-059) -------------------------------------------
|
# -- Noise animation cycles (D-059) -------------------------------------------
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func before_each() -> void:
|
|||||||
GameState.player_stance = "Walk"
|
GameState.player_stance = "Walk"
|
||||||
GameState.player_inventory = []
|
GameState.player_inventory = []
|
||||||
GameState.stationary_ticks = 0
|
GameState.stationary_ticks = 0
|
||||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||||
GameState.current_zone_id = ""
|
GameState.current_zone_id = ""
|
||||||
GameState.insert_active = true
|
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:
|
func test_apply_snapshot_sets_nearby_interactions() -> void:
|
||||||
var interactions := [
|
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})
|
GameState.apply_snapshot({"tick": 1, "entities": [], "nearby_interactions": interactions})
|
||||||
assert_that(GameState.nearby_interactions.size()).is_equal(1)
|
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) -----------------------------------------
|
# -- v5: current_monologue (#414) -----------------------------------------
|
||||||
|
|
||||||
func test_apply_snapshot_sets_monologue() -> void:
|
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})
|
GameState.apply_snapshot({"tick": 1, "entities": [], "current_monologue": monologue})
|
||||||
assert_that(GameState.current_monologue).is_not_null()
|
assert_that(GameState.current_monologue).is_not_null()
|
||||||
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
|
assert_that(GameState.current_monologue.get("text")).is_equal("Something is off here.")
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ extends GdUnitTestSuite
|
|||||||
|
|
||||||
func before_each() -> void:
|
func before_each() -> void:
|
||||||
GameState.stationary_ticks = 0
|
GameState.stationary_ticks = 0
|
||||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||||
GameState.current_zone_id = ""
|
GameState.current_zone_id = ""
|
||||||
GameState.player_position = Vector2.ZERO
|
GameState.player_position = Vector2.ZERO
|
||||||
GameState.visible_tiles = []
|
GameState.visible_tiles = []
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
class_name TestHubTeleport
|
class_name TestHubTeleport
|
||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
# -- Fixtures ------------------------------------------------------------------
|
# -- 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).
|
# Threshold constant lives on the main scene node (TELEPORT_DISTANCE_THRESHOLD = 5.0).
|
||||||
# These tests verify the distance math against that threshold.
|
# 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:
|
func test_detect_teleport_large_jump() -> void:
|
||||||
# Position jump > threshold should be detected as teleport
|
# Position jump > threshold should be detected as teleport
|
||||||
var old_pos := Vector2(10.0, 10.0)
|
var old_pos := Vector2(10.0, 10.0)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func _make_cursor_or_skip() -> Node:
|
|||||||
|
|
||||||
func _make_interaction_list() -> Node:
|
func _make_interaction_list() -> Node:
|
||||||
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
|
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):
|
if ResourceLoader.exists(path):
|
||||||
var scene = load(path)
|
var scene = load(path)
|
||||||
var node = scene.instantiate()
|
var node = scene.instantiate()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ extends GdUnitTestSuite
|
|||||||
|
|
||||||
func _interaction_list_exists() -> bool:
|
func _interaction_list_exists() -> bool:
|
||||||
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
|
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):
|
if ResourceLoader.exists(path):
|
||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
@@ -20,7 +20,7 @@ func _interaction_list_exists() -> bool:
|
|||||||
|
|
||||||
func _make_interaction_list() -> Node:
|
func _make_interaction_list() -> Node:
|
||||||
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
|
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):
|
if ResourceLoader.exists(path):
|
||||||
var scene = load(path)
|
var scene = load(path)
|
||||||
var node = scene.instantiate()
|
var node = scene.instantiate()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void:
|
|||||||
"version": Protocol.PROTOCOL_VERSION,
|
"version": Protocol.PROTOCOL_VERSION,
|
||||||
"entities": [
|
"entities": [
|
||||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player",
|
{"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": [{
|
"nearby_interactions": [{
|
||||||
"entity_id": 2,
|
"entity_id": 2,
|
||||||
@@ -93,7 +93,7 @@ func test_protocol_decode_v4_entity_relationship() -> void:
|
|||||||
"version": Protocol.PROTOCOL_VERSION,
|
"version": Protocol.PROTOCOL_VERSION,
|
||||||
"entities": [
|
"entities": [
|
||||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc",
|
{"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)
|
var encoded = Messagepack.encode(raw)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
## Smoke tests for main scene initialization
|
## 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
|
class_name TestMainScene
|
||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ extends GdUnitTestSuite
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
|
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
|
||||||
|
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||||
|
|
||||||
func _make_minimap() -> Control:
|
func _make_minimap() -> Control:
|
||||||
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
|
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"):
|
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||||
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
|
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
|
||||||
return
|
return
|
||||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
var scene: Node = MAIN_SCENE.instantiate()
|
||||||
auto_free(scene)
|
auto_free(scene)
|
||||||
add_child(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"):
|
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||||
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
|
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
|
||||||
return
|
return
|
||||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
var scene: Node = MAIN_SCENE.instantiate()
|
||||||
auto_free(scene)
|
auto_free(scene)
|
||||||
add_child(scene)
|
add_child(scene)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class_name TestP0Regressions
|
|||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||||
|
|
||||||
var _instance: Node = null
|
var _instance: Node = null
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +176,7 @@ func test_monologue_carry_forward_preserves_newest() -> void:
|
|||||||
|
|
||||||
func test_camera_static_during_pause() -> void:
|
func test_camera_static_during_pause() -> void:
|
||||||
# 1. Instantiate main scene — camera anchors at test mode player position
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -204,7 +206,7 @@ func test_camera_static_during_pause() -> void:
|
|||||||
|
|
||||||
func test_camera_anchored_after_pause_unpause() -> void:
|
func test_camera_anchored_after_pause_unpause() -> void:
|
||||||
# Verify camera stays properly anchored through a pause → unpause cycle.
|
# Verify camera stays properly anchored through a pause → unpause cycle.
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ func test_full_v6_snapshot_decode() -> void:
|
|||||||
],
|
],
|
||||||
"entities": [
|
"entities": [
|
||||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player",
|
{"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": [
|
"visible_tiles": [
|
||||||
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "tile_kind": "Floor"},
|
{"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}],
|
"player_inventory": [{"item_id": 100, "name": "Access Token", "slot": 0}],
|
||||||
"entities": [
|
"entities": [
|
||||||
{"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player",
|
{"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": [],
|
"visible_tiles": [],
|
||||||
"nearby_interactions": [],
|
"nearby_interactions": [],
|
||||||
|
|||||||
@@ -281,7 +281,8 @@ func test_entity_renderer_npc_uses_unknown_teal() -> void:
|
|||||||
|
|
||||||
func test_entity_renderer_object_uses_grey() -> void:
|
func test_entity_renderer_object_uses_grey() -> void:
|
||||||
var renderer := _make_entity_renderer()
|
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)
|
renderer.update_entities(obj)
|
||||||
var node = renderer.entity_nodes[3] as Sprite2D
|
var node = renderer.entity_nodes[3] as Sprite2D
|
||||||
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
assert_that(node.self_modulate).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
class_name TestSessionManagerSprint19
|
class_name TestSessionManagerSprint19
|
||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
const MAIN_MENU_SCENE = preload("res://scenes/main_menu.tscn")
|
||||||
|
|
||||||
# Game IDs created during the current test — deleted in after_test().
|
# Game IDs created during the current test — deleted in after_test().
|
||||||
var _created_ids: Array = []
|
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"):
|
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||||
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
||||||
return
|
return
|
||||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||||
auto_free(scene)
|
auto_free(scene)
|
||||||
add_child(scene)
|
add_child(scene)
|
||||||
assert_that(scene).is_not_null()
|
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:
|
func test_main_menu_has_new_game_button() -> void:
|
||||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||||
return
|
return
|
||||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||||
auto_free(scene)
|
auto_free(scene)
|
||||||
add_child(scene)
|
add_child(scene)
|
||||||
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
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:
|
func test_main_menu_has_continue_button() -> void:
|
||||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||||
return
|
return
|
||||||
var scene: Node = load("res://scenes/main_menu.tscn").instantiate()
|
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||||
auto_free(scene)
|
auto_free(scene)
|
||||||
add_child(scene)
|
add_child(scene)
|
||||||
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
class_name TestSignalSprint24
|
class_name TestSignalSprint24
|
||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn")
|
||||||
|
|
||||||
|
|
||||||
# -- #588: Character archetype field ------------------------------------------
|
# -- #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:
|
func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void:
|
||||||
# update_from_state() must hide ticker when current_ticker is null.
|
# 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()
|
assert_that(ticker_scene).is_not_null()
|
||||||
var ticker := ticker_scene.instantiate()
|
var ticker := ticker_scene.instantiate()
|
||||||
auto_free(ticker)
|
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:
|
func test_news_ticker_visible_when_snapshot_has_ticker() -> void:
|
||||||
# update_from_state() must show ticker when current_ticker has text.
|
# 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()
|
assert_that(ticker_scene).is_not_null()
|
||||||
var ticker := ticker_scene.instantiate()
|
var ticker := ticker_scene.instantiate()
|
||||||
auto_free(ticker)
|
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:
|
func test_news_ticker_hides_when_ticker_becomes_null() -> void:
|
||||||
# Ticker shown then hidden: update_from_state() with null current_ticker hides it.
|
# 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()
|
assert_that(ticker_scene).is_not_null()
|
||||||
var ticker := ticker_scene.instantiate()
|
var ticker := ticker_scene.instantiate()
|
||||||
auto_free(ticker)
|
auto_free(ticker)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
class_name TestSmoothCameraSprint15
|
class_name TestSmoothCameraSprint15
|
||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||||
|
|
||||||
var _instance: Node = null
|
var _instance: Node = null
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ func test_camera_smoothing_speed_constant_reasonable() -> void:
|
|||||||
|
|
||||||
func test_godot_smoothing_disabled_at_ready() -> void:
|
func test_godot_smoothing_disabled_at_ready() -> void:
|
||||||
# #117: Godot's built-in Camera2D smoothing must be OFF (manual lerp replaces it).
|
# #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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_godot_smoothing_stays_off_after_frames() -> void:
|
||||||
# #117: Smoothing must NOT be re-enabled at any point — manual lerp only.
|
# #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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_camera_lerps_not_snaps_on_player_move() -> void:
|
||||||
# #117: When player moves, camera should lerp (not snap) to new position.
|
# #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.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_camera_converges_to_player_over_multiple_frames() -> void:
|
||||||
# #117: After enough frames the camera should be within 1px of target.
|
# #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.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_camera_stationary_player_no_drift() -> void:
|
||||||
# #117: When player is stationary, camera should not drift (lerp to same point).
|
# #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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -133,7 +135,7 @@ func test_camera_stationary_player_no_drift() -> void:
|
|||||||
func test_teleport_snaps_camera_immediately() -> void:
|
func test_teleport_snaps_camera_immediately() -> void:
|
||||||
# #117: _teleport_in_progress causes camera to snap (not lerp) in the same frame.
|
# #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.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -155,7 +157,7 @@ func test_teleport_snaps_camera_immediately() -> void:
|
|||||||
|
|
||||||
func test_teleport_flag_cleared_after_snap() -> void:
|
func test_teleport_flag_cleared_after_snap() -> void:
|
||||||
# #117: _teleport_in_progress must be false after the snap frame.
|
# #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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -170,7 +172,7 @@ func test_teleport_flag_cleared_after_snap() -> void:
|
|||||||
func test_camera_resumes_lerp_after_teleport() -> void:
|
func test_camera_resumes_lerp_after_teleport() -> void:
|
||||||
# #117: Frame after teleport snap must resume lerp (not continue snapping).
|
# #117: Frame after teleport snap must resume lerp (not continue snapping).
|
||||||
# After teleport flag clears, any position delta produces lerp movement.
|
# After teleport flag clears, any position delta produces lerp movement.
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -200,7 +202,7 @@ func test_camera_resumes_lerp_after_teleport() -> void:
|
|||||||
|
|
||||||
func test_camera_no_rotation() -> void:
|
func test_camera_no_rotation() -> void:
|
||||||
# D-015: Camera must be fixed-north in v0.1 — no rotation regardless of facing.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
class_name TestTimeDisplaySprint17
|
class_name TestTimeDisplaySprint17
|
||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||||
|
|
||||||
var _clock: Control = null
|
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:
|
func test_insert_clock_exists_in_ui_layer() -> void:
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
var instance = scene.instantiate()
|
var instance = scene.instantiate()
|
||||||
auto_free(instance)
|
auto_free(instance)
|
||||||
add_child(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()
|
assert_that(instance.get_node_or_null("InsertOverlay/TimeDisplay")).is_not_null()
|
||||||
|
|
||||||
func test_insert_clock_time_str_updates_after_process() -> void:
|
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()
|
var instance = scene.instantiate()
|
||||||
auto_free(instance)
|
auto_free(instance)
|
||||||
add_child(instance)
|
add_child(instance)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
class_name TestUIFrameworkSprint15
|
class_name TestUIFrameworkSprint15
|
||||||
extends GdUnitTestSuite
|
extends GdUnitTestSuite
|
||||||
|
|
||||||
|
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||||
|
|
||||||
var _instance: Node = null
|
var _instance: Node = null
|
||||||
|
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ func test_dialogue_max_width_set() -> void:
|
|||||||
func test_insert_overlay_is_canvas_layer_10() -> void:
|
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||||
# D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10.
|
# D-049: InsertOverlay = conceptual layer 6 (insert scope) = CanvasLayer 10.
|
||||||
# Constants.CANVAS_INSERT must match.
|
# Constants.CANVAS_INSERT must match.
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_ui_layer_is_canvas_layer_20() -> void:
|
||||||
# D-049: UILayer = conceptual layer 7 (UI/monologue scope) = CanvasLayer 20.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_modal_layer_is_canvas_layer_30() -> void:
|
||||||
# D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30.
|
# D-049: ModalLayer = pause/inventory modal scope = CanvasLayer 30.
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_monologue_display_exists_in_ui_layer() -> void:
|
||||||
# D-049 / #117 / #414: MonologueDisplay must be in UILayer (layer 7).
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_stance_indicator_exists_in_ui_layer() -> void:
|
||||||
# D-053: StanceIndicator must be in UILayer (layer 7), top-right, color-coded.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_minimap_placeholder_exists_in_ui_layer() -> void:
|
||||||
# D-013/D-049: Minimap is on InsertOverlay (z-layer 6), NOT UILayer.
|
# 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.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_hud_exists_in_ui_layer() -> void:
|
||||||
# D-049: HUD must be in UILayer.
|
# D-049: HUD must be in UILayer.
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_interaction_list_exists_in_insert_overlay() -> void:
|
||||||
# D-057: InteractionList must be in InsertOverlay (z-layer 6).
|
# D-057: InteractionList must be in InsertOverlay (z-layer 6).
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_dialogue_box_exists_in_insert_overlay() -> void:
|
||||||
# D-061: DialogueBox must be in InsertOverlay (z-layer 6).
|
# D-061: DialogueBox must be in InsertOverlay (z-layer 6).
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_world_radial_exists_in_insert_overlay() -> void:
|
||||||
# D-058: WorldRadial must be in InsertOverlay (z-layer 6).
|
# D-058: WorldRadial must be in InsertOverlay (z-layer 6).
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_cursor_renderer_exists_in_ui_layer() -> void:
|
||||||
# D-056: CursorRenderer must be in UILayer (topmost, z-layer 7).
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -182,7 +184,7 @@ func test_gamestate_insert_active_defaults_true() -> void:
|
|||||||
func test_insert_active_propagates_on_process() -> void:
|
func test_insert_active_propagates_on_process() -> void:
|
||||||
# OQ-07 (#522): After apply_snapshot with insert_active=false,
|
# OQ-07 (#522): After apply_snapshot with insert_active=false,
|
||||||
# the next _process() call must propagate the state to z-layer-6 nodes.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_monologue_display_receives_first_tick_monologue() -> void:
|
||||||
# #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot.
|
# #414 / #74: MonologueDisplay must show monologue from tick 1 test snapshot.
|
||||||
# Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay.
|
# Verifies the wiring: GameState.current_monologue → main.gd → MonologueDisplay.
|
||||||
var scene := load("res://scenes/main.tscn")
|
var scene := MAIN_SCENE
|
||||||
_instance = scene.instantiate()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_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:
|
func test_fog_group_exists_in_world() -> void:
|
||||||
# Sprint 14 regression: fog rendering must still be present after sprint 15 changes.
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -264,7 +266,7 @@ func test_fog_group_exists_in_world() -> void:
|
|||||||
|
|
||||||
func test_floor_tiles_in_fog_group() -> void:
|
func test_floor_tiles_in_fog_group() -> void:
|
||||||
# Sprint 14 regression: FloorTiles must be in FogGroup (D-049 z:0).
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -274,7 +276,7 @@ func test_floor_tiles_in_fog_group() -> void:
|
|||||||
|
|
||||||
func test_entities_in_ysort_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).
|
# 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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
@@ -288,7 +290,7 @@ func test_entities_in_ysort_group() -> void:
|
|||||||
|
|
||||||
func test_tile_renderer_skips_nonzero_z() -> void:
|
func test_tile_renderer_skips_nonzero_z() -> void:
|
||||||
# #71: Tiles with z != 0 must be filtered out by update_tiles().
|
# #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()
|
_instance = scene.instantiate()
|
||||||
auto_free(_instance)
|
auto_free(_instance)
|
||||||
add_child(_instance)
|
add_child(_instance)
|
||||||
|
|||||||
@@ -250,7 +250,8 @@ func test_dialogue_theme_format() -> void:
|
|||||||
|
|
||||||
func test_checklist_format() -> void:
|
func test_checklist_format() -> void:
|
||||||
## checklist.yaml: top-level kv + conditions array with typed values.
|
## 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)
|
var result := YamlParser.parse(yaml)
|
||||||
assert_that(result["room_id"]).is_equal("inventory_warehouse")
|
assert_that(result["room_id"]).is_equal("inventory_warehouse")
|
||||||
var cond: Dictionary = result["conditions"][0]
|
var cond: Dictionary = result["conditions"][0]
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func _init():
|
|||||||
_run.call_deferred()
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
func _run():
|
func _run(): # gdlint:disable=max-returns
|
||||||
# Parse CLI args (after -- separator)
|
# Parse CLI args (after -- separator)
|
||||||
_parse_args()
|
_parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ func apply_setup(scenario_name: String, tree_root: Node) -> bool:
|
|||||||
sim_bridge.harness.player_pos = Vector2i(10, 10)
|
sim_bridge.harness.player_pos = Vector2i(10, 10)
|
||||||
# Zone tint is written from visible_tiles with zone_id field.
|
# Zone tint is written from visible_tiles with zone_id field.
|
||||||
# We patch GameState.visible_tiles after the first snapshot in post_setup().
|
# We patch GameState.visible_tiles after the first snapshot in post_setup().
|
||||||
pass
|
|
||||||
|
|
||||||
"fog_debug":
|
"fog_debug":
|
||||||
# Raw exploration overlay (green/blue/red).
|
# Raw exploration overlay (green/blue/red).
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# gdlint:disable=max-file-lines
|
||||||
class_name CharacterCreation
|
class_name CharacterCreation
|
||||||
extends Control
|
extends Control
|
||||||
## #705: Character creation screen.
|
## #705: Character creation screen.
|
||||||
@@ -105,18 +106,14 @@ const PALETTE_COLORS: Array[Array] = [
|
|||||||
const PALETTE_COLS := 9
|
const PALETTE_COLS := 9
|
||||||
const RECENT_SLOTS := 9
|
const RECENT_SLOTS := 9
|
||||||
|
|
||||||
# --- @onready references to .tscn nodes ---
|
const CAM_ZOOM_MIN: float = 0.25 # closest zoom (face detail)
|
||||||
@onready var _viewport: SubViewport = $Layout/PreviewPanel/SubViewportContainer/SubViewport
|
const CAM_ZOOM_MAX: float = 3.0 # farthest zoom (crowd level)
|
||||||
@onready var _char_anchor: Node3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/CharacterVisualAnchor
|
const CAM_ZOOM_STEP: float = 0.12
|
||||||
@onready var _preview_camera: Camera3D = $Layout/PreviewPanel/SubViewportContainer/SubViewport/PreviewCamera
|
|
||||||
@onready var _rotate_left_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateLeftBtn
|
const MANIFEST_PATH := "res://assets/characters/manifest.json"
|
||||||
@onready var _rotate_right_btn: Button = $Layout/PreviewPanel/PreviewOverlay/RotateButtons/RotateRightBtn
|
|
||||||
@onready var _cam_angle_btn: Button = $Layout/PreviewPanel/PreviewOverlay/CamAngleBtn
|
const SCREENSHOT_DIR := "user://screenshots/"
|
||||||
var _tab_container: TabContainer = null # resolved in _ready() — @onready path fails when instantiated as child
|
const CARDINAL_NAMES: Array[String] = ["south", "east", "north", "west"]
|
||||||
@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
|
|
||||||
|
|
||||||
# --- Descriptor and preview state ---
|
# --- Descriptor and preview state ---
|
||||||
var _descriptor: CharacterVisualDescriptor
|
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_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: float = 1.0 # 1.0 = default distance, <1.0 = zoomed in
|
||||||
var _cam_zoom_offset: Vector3 = Vector3.ZERO # camera offset toward cursor when zoomed
|
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 ---
|
# --- Tab active slot state ---
|
||||||
var _active_clothing_slot: String = "torso"
|
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) ---
|
# --- Asset manifest (loaded once, replaces filesystem scanning) ---
|
||||||
var _manifest: Dictionary = {}
|
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:
|
if _char_visual and _char_visual._skeleton:
|
||||||
var head_idx := _char_visual._skeleton.find_bone("Head")
|
var head_idx := _char_visual._skeleton.find_bone("Head")
|
||||||
if head_idx >= 0:
|
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
|
# Blend from body center toward head as zoom increases
|
||||||
var blend := 1.0 - _cam_zoom # 0 at default, 0.75 at max zoom
|
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
|
_cam_zoom_offset.y = (head_pos.y - CAM_TARGET_HEIGHT) * blend
|
||||||
@@ -1084,15 +1103,6 @@ func _update_accessory_item_btns() -> void:
|
|||||||
# Screenshot & automated testing
|
# 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:
|
func _schedule_screenshot() -> void:
|
||||||
_screenshot_pending = true
|
_screenshot_pending = true
|
||||||
_screenshot_frame_count = 0
|
_screenshot_frame_count = 0
|
||||||
@@ -1131,8 +1141,7 @@ func _take_screenshot(suffix: String = "") -> void:
|
|||||||
# More directions to capture
|
# More directions to capture
|
||||||
_schedule_screenshot()
|
_schedule_screenshot()
|
||||||
return
|
return
|
||||||
else:
|
_screenshot_cardinals = false
|
||||||
_screenshot_cardinals = false
|
|
||||||
|
|
||||||
if _quit_after_screenshot:
|
if _quit_after_screenshot:
|
||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
@@ -1203,8 +1212,6 @@ func _load_test_config() -> void:
|
|||||||
# Debug tab — segment visibility toggles
|
# Debug tab — segment visibility toggles
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
var _debug_toggles: Dictionary = {} # seg_name -> CheckButton
|
|
||||||
|
|
||||||
func _build_debug_tab(tab: Control) -> void:
|
func _build_debug_tab(tab: Control) -> void:
|
||||||
var vbox := _make_tab_vbox(tab)
|
var vbox := _make_tab_vbox(tab)
|
||||||
|
|
||||||
@@ -1501,7 +1508,7 @@ func _input(event: InputEvent) -> void:
|
|||||||
_cam_zoom_toward_cursor(mb.position, -CAM_ZOOM_STEP)
|
_cam_zoom_toward_cursor(mb.position, -CAM_ZOOM_STEP)
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
return
|
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)
|
_cam_zoom_toward_cursor(mb.position, CAM_ZOOM_STEP)
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
return
|
return
|
||||||
@@ -1873,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"))
|
"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"))
|
"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"))
|
"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.
|
## 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 BG_COLOR := Color(0.05, 0.05, 0.08, 0.45)
|
||||||
const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met
|
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:
|
func _ready() -> void:
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
visible = false
|
visible = false
|
||||||
_evaluator = _ChecklistEvaluator.new()
|
_evaluator = ChecklistEvaluator.new()
|
||||||
_cached_font = get_theme_default_font()
|
_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
|
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||||
for i in range(line_count):
|
for i in range(line_count):
|
||||||
if i < left_lines.size():
|
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, y), left_lines[i][0] + ": ",
|
||||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), left_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
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():
|
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, y), right_lines[i][0] + ": ",
|
||||||
draw_string(font, Vector2(right_x + right_label_w, y), right_lines[i][1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
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
|
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 a_screen := _w2s(path[i - 1], canvas_xf)
|
||||||
var b_screen := _w2s(path[i], canvas_xf)
|
var b_screen := _w2s(path[i], canvas_xf)
|
||||||
var alpha := float(i) / float(path.size())
|
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)
|
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_requested(profile: String)
|
||||||
signal audio_dip_cleared
|
signal audio_dip_cleared
|
||||||
|
|
||||||
@onready var panel: PanelContainer = $PanelContainer
|
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
|
||||||
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
|
||||||
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
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 --
|
# -- Log state --
|
||||||
# Entry format (legacy): {speaker: String, target: String, text, is_passive, pinned, timestamp_msec}
|
# 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_lifetime: float = 45.0
|
||||||
var _entry_fade: float = 5.0
|
var _entry_fade: float = 5.0
|
||||||
|
|
||||||
const THEME_PATH: String = "res://data/dialogue-theme.yaml"
|
@onready var panel: PanelContainer = $PanelContainer
|
||||||
|
@onready var dialogue_log: RichTextLabel = $PanelContainer/MarginContainer/VBoxContainer/DialogueLog
|
||||||
const FADE_IN: float = 0.2
|
@onready var options_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/OptionsContainer
|
||||||
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",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -197,7 +197,8 @@ func _update_layout() -> void:
|
|||||||
## Active conversation entries are pinned (no timeout) while _in_player_conversation.
|
## 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).
|
## 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.
|
## 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 pinned := not is_passive and _in_player_conversation
|
||||||
var entry: Dictionary = {
|
var entry: Dictionary = {
|
||||||
"speaker": speaker,
|
"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]" % [
|
return "%s[color=%s][b]%s[/b][/color][color=%s]: %s[/color]" % [
|
||||||
prefix, sc, speaker, txc, text
|
prefix, sc, speaker, txc, text
|
||||||
]
|
]
|
||||||
else:
|
# Full "Speaker → Target: text" for overheard
|
||||||
# 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]" % [
|
||||||
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
|
||||||
prefix, sc, speaker, ac, tc, target, txc, text
|
]
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ const CONFIDENCE_ALPHA: Dictionary = {
|
|||||||
"Suspects": 0.6,
|
"Suspects": 0.6,
|
||||||
}
|
}
|
||||||
|
|
||||||
@onready var panel: PanelContainer = $PanelContainer
|
|
||||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
|
|
||||||
|
|
||||||
var _dismiss_tween: Tween = null
|
var _dismiss_tween: Tween = null
|
||||||
var _active: bool = false
|
var _active: bool = false
|
||||||
|
|
||||||
|
@onready var panel: PanelContainer = $PanelContainer
|
||||||
|
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/TextLabel
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
modulate.a = 0.0
|
modulate.a = 0.0
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ func _draw() -> void:
|
|||||||
# PB text (different color)
|
# PB text (different color)
|
||||||
if not pb_text.is_empty():
|
if not pb_text.is_empty():
|
||||||
var pb_color: Color = NEW_PB_COLOR if _new_pb_flash > 0.0 else PB_COLOR
|
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:
|
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
|
const TPT_DEGRADATION_THRESHOLD := 0.4 # fraction — >40% sustained drop → yellow
|
||||||
|
|
||||||
## Benchmark cache path — reads from PlatformInfo for cross-platform correctness.
|
## Benchmark cache path — reads from PlatformInfo for cross-platform correctness.
|
||||||
var BENCHMARK_CACHE_PATH: String:
|
var benchmark_cache_path: String:
|
||||||
get:
|
get:
|
||||||
return PlatformInfo.benchmark_cache_path
|
return PlatformInfo.benchmark_cache_path
|
||||||
|
|
||||||
@@ -45,10 +45,9 @@ func _ready() -> void:
|
|||||||
func classify_ram(free_mb: float) -> String:
|
func classify_ram(free_mb: float) -> String:
|
||||||
if free_mb >= RAM_PASS_THRESHOLD_MB:
|
if free_mb >= RAM_PASS_THRESHOLD_MB:
|
||||||
return "pass"
|
return "pass"
|
||||||
elif free_mb >= RAM_MARGINAL_THRESHOLD_MB:
|
if free_mb >= RAM_MARGINAL_THRESHOLD_MB:
|
||||||
return "marginal"
|
return "marginal"
|
||||||
else:
|
return "fail"
|
||||||
return "fail"
|
|
||||||
|
|
||||||
|
|
||||||
## Query RAM via PlatformInfo and return classification + raw MB value.
|
## Query RAM via PlatformInfo and return classification + raw MB value.
|
||||||
@@ -65,10 +64,9 @@ func check_ram() -> Dictionary:
|
|||||||
func classify_tpt(tps: float) -> String:
|
func classify_tpt(tps: float) -> String:
|
||||||
if tps >= TPT_GREEN_THRESHOLD:
|
if tps >= TPT_GREEN_THRESHOLD:
|
||||||
return "green"
|
return "green"
|
||||||
elif tps >= TPT_YELLOW_THRESHOLD:
|
if tps >= TPT_YELLOW_THRESHOLD:
|
||||||
return "yellow"
|
return "yellow"
|
||||||
else:
|
return "red"
|
||||||
return "red"
|
|
||||||
|
|
||||||
|
|
||||||
## Read the cached TPT benchmark result written by the server on first model load.
|
## 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.
|
## Returns empty string when no message is needed.
|
||||||
func status_message(ram_classification: String, tpt_classification: String, free_mb: float, tps: float) -> String:
|
func status_message(ram_classification: String, tpt_classification: String, free_mb: float, tps: float) -> String:
|
||||||
if ram_classification == "fail":
|
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":
|
if ram_classification == "marginal":
|
||||||
return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb
|
return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb
|
||||||
match tpt_classification:
|
match tpt_classification:
|
||||||
"yellow":
|
"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":
|
"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 ""
|
return ""
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ var _verb_labels: Array[Label] = []
|
|||||||
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
|
# #537: D-033 relationship color — cached per target, drawn as left-edge accent bar
|
||||||
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
|
var _relationship_color: Color = Constants.IMPLANT_TEXT_DIM
|
||||||
|
|
||||||
|
var _last_screen_pos: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
@onready var _vbox: VBoxContainer = $VBox
|
@onready var _vbox: VBoxContainer = $VBox
|
||||||
|
|
||||||
|
|
||||||
@@ -40,9 +42,6 @@ func _ready() -> void:
|
|||||||
visible = false
|
visible = false
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
|
|
||||||
var _last_screen_pos: Vector2 = Vector2.ZERO
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
if _showing:
|
if _showing:
|
||||||
_update_screen_position()
|
_update_screen_position()
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ extends PanelContainer
|
|||||||
# v0.2: Will be replaced/extended with radial verb menu.
|
# v0.2: Will be replaced/extended with radial verb menu.
|
||||||
# Public interface: get_interaction_target(), get_selected_verb()
|
# 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 _is_showing: bool = false
|
||||||
var _active_tween: Tween = null
|
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)
|
# OQ-07 (#522): when false, prompt is suppressed (z-layer 6 insert overlay only)
|
||||||
var _insert_active: bool = true
|
var _insert_active: bool = true
|
||||||
|
|
||||||
const FADE_IN: float = 0.15
|
@onready var prompt_label: Label = $MarginContainer/PromptLabel
|
||||||
const FADE_OUT: float = 0.15
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
modulate.a = 0.0
|
modulate.a = 0.0
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ func _draw() -> void:
|
|||||||
|
|
||||||
# Hotkey number (top-left corner)
|
# Hotkey number (top-left corner)
|
||||||
var hotkey := str(slot_idx + 1)
|
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:
|
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}
|
# Keys: knowledge_panel.confidence_{lower} and knowledge_panel.source_{lower}
|
||||||
# Fallback: raw value if key not found (UIStrings returns the key itself).
|
# 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 panel: PanelContainer = $PanelContainer
|
||||||
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
|
@onready var title_label: Label = $PanelContainer/MarginContainer/VBoxContainer/TitleLabel
|
||||||
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
|
@onready var scroll: ScrollContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer
|
||||||
@onready var entries_container: VBoxContainer = $PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
@onready var entries_container: VBoxContainer = \
|
||||||
|
$PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer
|
||||||
var _visible_state: bool = false
|
|
||||||
var _last_rendered_tick: int = -1
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
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.
|
## 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
|
visible = false
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const FONT_SIZE_TITLE := 36
|
|||||||
const FONT_SIZE_SUBTITLE := 14
|
const FONT_SIZE_SUBTITLE := 14
|
||||||
const FONT_SIZE_BTN := 15
|
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 _new_game_btn: Button = $VBox/NewGameBtn
|
||||||
@onready var _continue_btn: Button = $VBox/ContinueBtn
|
@onready var _continue_btn: Button = $VBox/ContinueBtn
|
||||||
@onready var _load_game_btn: Button = $VBox/LoadGameBtn
|
@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 _saves_list: VBoxContainer = $LoadGamePanel/VBox/SavesScroll/SavesList
|
||||||
@onready var _load_back_btn: Button = $LoadGamePanel/VBox/BackBtn
|
@onready var _load_back_btn: Button = $LoadGamePanel/VBox/BackBtn
|
||||||
|
|
||||||
var _char_creation: Control = null
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_new_game_btn.pressed.connect(_on_new_game)
|
_new_game_btn.pressed.connect(_on_new_game)
|
||||||
@@ -93,9 +94,6 @@ func _on_continue() -> void:
|
|||||||
get_tree().change_scene_to_file(GAME_SCENE)
|
get_tree().change_scene_to_file(GAME_SCENE)
|
||||||
|
|
||||||
|
|
||||||
var _list_built: bool = false
|
|
||||||
|
|
||||||
|
|
||||||
func _on_load_game_browse() -> void:
|
func _on_load_game_browse() -> void:
|
||||||
if not _list_built:
|
if not _list_built:
|
||||||
_build_saves_list()
|
_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_COLOR: Color = Color("#8890a0") # #554: neutral system notification
|
||||||
const _NOTIFICATION_DURATION: float = 2.5
|
const _NOTIFICATION_DURATION: float = 2.5
|
||||||
|
|
||||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
|
||||||
|
|
||||||
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
|
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
|
||||||
var _visible: Array[Dictionary] = []
|
var _visible: Array[Dictionary] = []
|
||||||
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
|
# 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)
|
# Msec timestamp when the next fade-in may begin (stagger enforcement)
|
||||||
var _next_fade_in_msec: float = 0.0
|
var _next_fade_in_msec: float = 0.0
|
||||||
|
|
||||||
|
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
pass
|
pass
|
||||||
@@ -85,7 +85,10 @@ func show_notification(text: String) -> void:
|
|||||||
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
||||||
_show_notification_line(text)
|
_show_notification_line(text)
|
||||||
else:
|
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:
|
if _queue.size() < MAX_QUEUE:
|
||||||
_queue.append(entry)
|
_queue.append(entry)
|
||||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
_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:
|
func _enqueue(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||||
if _queue.size() < MAX_QUEUE:
|
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)
|
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||||
else:
|
else:
|
||||||
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
|
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
|
||||||
var lowest := _lowest_priority_idx()
|
var lowest := _lowest_priority_idx()
|
||||||
if priority >= _queue[lowest].priority:
|
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)
|
_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
|
# 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 SCROLL_SPEED := 60.0 # pixels per second
|
||||||
const BAR_HEIGHT := 28
|
const BAR_HEIGHT := 28
|
||||||
|
|
||||||
@onready var _label: Label = $TickerLabel
|
|
||||||
|
|
||||||
var _text: String = ""
|
var _text: String = ""
|
||||||
var _scroll_x: float = 0.0
|
var _scroll_x: float = 0.0
|
||||||
var _content_width: float = 0.0
|
var _content_width: float = 0.0
|
||||||
|
|
||||||
|
@onready var _label: Label = $TickerLabel
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ extends Control
|
|||||||
## Volumes persist via AudioManager._save_prefs() on each slider change.
|
## Volumes persist via AudioManager._save_prefs() on each slider change.
|
||||||
## AI Dialogue toggle persists via ConfigFile (client-local) + ChangeSettings IPC (server SQLite).
|
## 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 BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
|
||||||
const BORDER_COLOR := Color("#4a9ebb")
|
const BORDER_COLOR := Color("#4a9ebb")
|
||||||
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
|
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_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
|
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:
|
func _ready() -> void:
|
||||||
visible = false
|
visible = false
|
||||||
|
|||||||
+136
-67
@@ -5,11 +5,12 @@ extends Control
|
|||||||
## Renders 301 systems as dots on concentric rings (hop distance from Gateway).
|
## 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).
|
## 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).
|
## Data source: res://data/star_map_data.json (generated from star-map.json + systems.db + wiki).
|
||||||
## Regenerate with: tooling/generate-star-map-data.py
|
## Regenerate with: tooling/generate-star-map-data.py
|
||||||
##
|
##
|
||||||
## D-013: Diegetic neural insert overlay. Accessible from the insert UI.
|
## D-013: Diegetic neural insert overlay. Accessible from the insert UI.
|
||||||
## Parent epic: #51 (Diegetic Insert/Minimap), ticket #674.
|
## 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"
|
const DATA_PATH := "res://data/star_map_data.json"
|
||||||
|
|
||||||
@@ -30,10 +31,17 @@ const GATEWAY_RADIUS: float = 6.0
|
|||||||
const SELECTION_RING_RADIUS: float = 8.0
|
const SELECTION_RING_RADIUS: float = 8.0
|
||||||
const HIT_RADIUS: float = 10.0 # click tolerance
|
const HIT_RADIUS: float = 10.0 # click tolerance
|
||||||
|
|
||||||
# Edge rendering
|
# Edge rendering — only shown for selected system (ticket #780 UX rule)
|
||||||
const EDGE_WIDTH: float = 0.4
|
const EDGE_WIDTH: float = 0.8
|
||||||
const EDGE_ALPHA: float = 0.12
|
const EDGE_SELECTED_ALPHA: float = 0.55
|
||||||
const EDGE_SELECTED_ALPHA: float = 0.5
|
|
||||||
|
# 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
|
# Colors — sector palette from wireframe
|
||||||
const COLOR_BG: Color = Color("#0d1117")
|
const COLOR_BG: Color = Color("#0d1117")
|
||||||
@@ -95,7 +103,6 @@ var _pan_start_offset: Vector2 = Vector2.ZERO
|
|||||||
|
|
||||||
var _data_loaded: bool = false
|
var _data_loaded: bool = false
|
||||||
var _insert_active: bool = true
|
var _insert_active: bool = true
|
||||||
var _show_edges: bool = false # toggle edge display
|
|
||||||
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
|
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +267,7 @@ func _sector_sort_key(node: Dictionary) -> float:
|
|||||||
"south_reach": return 3.0
|
"south_reach": return 3.0
|
||||||
"west_reach": return 4.0
|
"west_reach": return 4.0
|
||||||
"deep_frontier": return 5.0
|
"deep_frontier": return 5.0
|
||||||
_: return 6.0
|
_: return 6.0 # gdlint:ignore = max-returns
|
||||||
|
|
||||||
|
|
||||||
## Deterministic float in [-1, 1] from a string key.
|
## Deterministic float in [-1, 1] from a string key.
|
||||||
@@ -289,8 +296,8 @@ func _draw() -> void:
|
|||||||
# Sector labels
|
# Sector labels
|
||||||
_draw_sector_labels(center)
|
_draw_sector_labels(center)
|
||||||
|
|
||||||
# Edges (gate connections)
|
# Edges — only draw from selected system (UX rule: full edge web is too dense)
|
||||||
if _show_edges or _selected_system != "":
|
if _selected_system != "":
|
||||||
_draw_edges(center)
|
_draw_edges(center)
|
||||||
|
|
||||||
# System dots
|
# System dots
|
||||||
@@ -358,23 +365,18 @@ func _draw_systems(center: Vector2) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _draw_edges(center: Vector2) -> void:
|
func _draw_edges(center: Vector2) -> void:
|
||||||
for edge: Array in _edges:
|
# Only draw edges connected to the selected system (full web is unreadable at 301 systems)
|
||||||
if edge.size() < 2:
|
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
|
continue
|
||||||
var sid_a: String = edge[0]
|
var neighbor_pos: Vector2 = center + _node_positions[neighbor_id] * _zoom
|
||||||
var sid_b: String = edge[1]
|
draw_line(sel_pos, neighbor_pos, color, EDGE_WIDTH, true)
|
||||||
if not _node_positions.has(sid_a) or not _node_positions.has(sid_b):
|
|
||||||
continue
|
|
||||||
var pos_a: Vector2 = center + _node_positions[sid_a] * _zoom
|
|
||||||
var pos_b: Vector2 = center + _node_positions[sid_b] * _zoom
|
|
||||||
|
|
||||||
var alpha: float = EDGE_ALPHA
|
|
||||||
# Highlight edges connected to selected system
|
|
||||||
if _selected_system == sid_a or _selected_system == sid_b:
|
|
||||||
alpha = EDGE_SELECTED_ALPHA
|
|
||||||
|
|
||||||
var color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, alpha)
|
|
||||||
draw_line(pos_a, pos_b, color, EDGE_WIDTH, true)
|
|
||||||
|
|
||||||
|
|
||||||
func _draw_selection(center: Vector2) -> void:
|
func _draw_selection(center: Vector2) -> void:
|
||||||
@@ -389,7 +391,8 @@ func _draw_selection(center: Vector2) -> void:
|
|||||||
if label.is_empty():
|
if label.is_empty():
|
||||||
label = _selected_system
|
label = _selected_system
|
||||||
var font := get_theme_default_font()
|
var font := get_theme_default_font()
|
||||||
draw_string(font, pos + Vector2(SELECTION_RING_RADIUS + 4, 4), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION)
|
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:
|
func _draw_info_panel(sz: Vector2) -> void:
|
||||||
@@ -397,54 +400,125 @@ func _draw_info_panel(sz: Vector2) -> void:
|
|||||||
if node.is_empty():
|
if node.is_empty():
|
||||||
return
|
return
|
||||||
|
|
||||||
var panel_w: float = 220.0
|
|
||||||
var panel_h: float = 130.0
|
|
||||||
var margin: float = 16.0
|
|
||||||
var panel_pos := Vector2(sz.x - panel_w - margin, margin)
|
|
||||||
|
|
||||||
# Background
|
|
||||||
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)
|
|
||||||
|
|
||||||
var font := get_theme_default_font()
|
var font := get_theme_default_font()
|
||||||
var y: float = panel_pos.y + 20.0
|
var panel_w: float = POPUP_WIDTH
|
||||||
var x: float = panel_pos.x + 12.0
|
var pad: float = POPUP_PADDING
|
||||||
var line_h: float = 18.0
|
var inner_w: float = panel_w - pad * 2.0
|
||||||
|
|
||||||
# System name
|
# Measure GTTR text height first so we can size the panel
|
||||||
var name: String = node.get("proper_name", "")
|
var gttr: String = node.get("gttr_excerpt", "")
|
||||||
if name.is_empty():
|
# Strip markdown bold markers for display
|
||||||
name = node.get("system_id", "Unknown")
|
gttr = gttr.replace("**", "")
|
||||||
draw_string(font, Vector2(x, y), name, HORIZONTAL_ALIGNMENT_LEFT, -1, 14, COLOR_TEXT)
|
var gttr_block_h: float = 0.0
|
||||||
y += line_h
|
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
|
||||||
|
|
||||||
# System ID
|
# Fixed rows: name, id, star+hop, corridor, bodies+pop, adjacent, separator lines
|
||||||
draw_string(font, Vector2(x, y), node.get("system_id", ""), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, COLOR_TEXT_DIM)
|
var fixed_h: float = (
|
||||||
y += line_h
|
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
|
||||||
|
|
||||||
# Sector
|
var panel_pos := Vector2(sz.x - panel_w - POPUP_MARGIN, POPUP_MARGIN)
|
||||||
var sector: String = node.get("geographic_sector", "").replace("_", " ").capitalize()
|
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)
|
var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
|
||||||
draw_string(font, Vector2(x, y), "Sector: " + sector, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, sector_color)
|
draw_string(font, Vector2(x, y), sector_str + " corridor", HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, sector_color)
|
||||||
y += line_h
|
y += POPUP_LINE_H
|
||||||
|
|
||||||
# Hop distance
|
var bodies: String = node.get("bodies", "")
|
||||||
draw_string(font, Vector2(x, y), "Hop distance: " + str(node.get("hop_distance", "?")), HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
|
var population: String = node.get("population", "")
|
||||||
y += line_h
|
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
|
||||||
|
|
||||||
# Topology
|
# ── GTTR excerpt ─────────────────────────────────────────────────────────
|
||||||
var topo: String = node.get("gate_topology", "").replace("_", " ").capitalize()
|
if not gttr.is_empty():
|
||||||
draw_string(font, Vector2(x, y), "Topology: " + topo, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
|
draw_line(Vector2(x, y + 1.0), Vector2(panel_pos.x + panel_w - pad, y + 1.0),
|
||||||
y += line_h
|
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
|
||||||
|
|
||||||
# Gate connections
|
# ── Adjacent systems ──────────────────────────────────────────────────────
|
||||||
draw_string(font, Vector2(x, y), "Gates: " + str(node.get("aperture_count", 0)) + " apertures", HORIZONTAL_ALIGNMENT_LEFT, -1, 11, COLOR_TEXT_DIM)
|
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:
|
func _draw_title() -> void:
|
||||||
var font := get_theme_default_font()
|
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, 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)
|
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:
|
func _dot_radius(topology: String) -> float:
|
||||||
@@ -513,12 +587,7 @@ func _find_nearest_system(pos: Vector2) -> String:
|
|||||||
|
|
||||||
func _handle_click(pos: Vector2) -> void:
|
func _handle_click(pos: Vector2) -> void:
|
||||||
var nearest := _find_nearest_system(pos)
|
var nearest := _find_nearest_system(pos)
|
||||||
if nearest != "":
|
_selected_system = nearest
|
||||||
_selected_system = nearest
|
|
||||||
_show_edges = true
|
|
||||||
else:
|
|
||||||
_selected_system = ""
|
|
||||||
_show_edges = false
|
|
||||||
_dirty = true
|
_dirty = true
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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)*
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Sprint 31: Bedrock — Client 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/client`
|
||||||
|
**Agents:** Stig (UI), Tyre (architecture), Hoshe (QA)
|
||||||
|
|
||||||
|
## New Tickets
|
||||||
|
|
||||||
|
| # | Title | Blocked by |
|
||||||
|
|---|-------|------------|
|
||||||
|
| #780 | Star map click-through — wiki/GTTR popup on system select | — |
|
||||||
|
| #775 | Decompose main.gd and game_state.gd god objects | — |
|
||||||
|
|
||||||
|
Use `tooling/db/ticket show <id>` for full details.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- `decisions/architecture.md` — D-013 (diegetic UI), D-020 (client architecture)
|
||||||
|
- `decisions/scope.md` — development cascade, Phase 1 deliverable
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### #780 Star map click-through (Phase 1 deliverable)
|
||||||
|
The star map insert module (#674, Sprint 30) renders 301 systems as a hop-ring
|
||||||
|
view with click-to-select. Currently selecting a system updates `_selected_system`
|
||||||
|
and shows the name in a basic info panel.
|
||||||
|
|
||||||
|
**Deliver:** When a system is selected, show a popup/panel with:
|
||||||
|
- System name, star type, hop distance, corridor
|
||||||
|
- GTTR narrative excerpt (first paragraph of the wiki entry)
|
||||||
|
- Body count, population summary
|
||||||
|
- Adjacent systems (topology links)
|
||||||
|
|
||||||
|
Data source: `client/data/star_map_data.json` already contains most fields.
|
||||||
|
For GTTR text, either embed excerpts in the JSON (via `tooling/generate-star-map-data.py`)
|
||||||
|
or load from a separate text file at runtime.
|
||||||
|
|
||||||
|
**Key files:**
|
||||||
|
- `client/ui/star_map.gd` — existing star map (525 lines)
|
||||||
|
- `client/ui/star_map.tscn` — scene file
|
||||||
|
- `client/data/star_map_data.json` — 4363 lines, 301 systems
|
||||||
|
- `tooling/generate-star-map-data.py` — data generator (may need extending)
|
||||||
|
|
||||||
|
### #775 Decompose main.gd and game_state.gd (refactor)
|
||||||
|
Gemini code review flagged these as god objects:
|
||||||
|
- `client/scripts/main.gd` (28KB) — UI management, input, state transitions
|
||||||
|
- `client/scripts/autoloads/game_state.gd` (19KB) — global state, snapshots
|
||||||
|
|
||||||
|
**Approach:** Extract focused components. Suggested splits:
|
||||||
|
- `main.gd` → extract UI manager, insert state controller, input dispatcher
|
||||||
|
- `game_state.gd` → extract snapshot handler, character state, session state
|
||||||
|
|
||||||
|
**Constraint:** GDScript autoload parse-order rule (documented in CLAUDE.md).
|
||||||
|
Autoloads can't reference `class_name` types at top level. Any new autoloads
|
||||||
|
must follow the `load()` inline pattern. Prefer non-autoload scripts where possible.
|
||||||
|
|
||||||
|
## Dependency Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
#780 (star map popup) → standalone, Phase 1 priority
|
||||||
|
#775 (decompose god objects) → standalone, refactor track
|
||||||
|
```
|
||||||
|
|
||||||
|
Independent — work in parallel.
|
||||||
|
|
||||||
|
## PR Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||||
|
--title "feat(client): Sprint 31 — star map popup, main.gd decomposition" \
|
||||||
|
--description "body" --base main --head sprint-31/client
|
||||||
|
```
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Sprint 31: Bedrock — Copy 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/copy`
|
||||||
|
**Agents:** Mellanie (author), Paula (narrative), Miri (worldbuilding)
|
||||||
|
|
||||||
|
## New Tickets
|
||||||
|
|
||||||
|
| # | Title | Blocked by |
|
||||||
|
|---|-------|------------|
|
||||||
|
| #766 | Cultural diversity sweep — wiki naming and heritage asymmetry | — |
|
||||||
|
| #782 | Per-body GTTR entries for moons and stations | — |
|
||||||
|
| #774 | Enrich GJ-672 (Nova Estrada) and GJ-147 (Entremeio) wiki entries | — |
|
||||||
|
| #773 | Evaluate Iserlohn (GJ-532) planet name for IP originality | — |
|
||||||
|
|
||||||
|
Use `tooling/db/ticket show <id>` for full details.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- `decisions/content.md` — D-NNN content decisions, cultural corridor system
|
||||||
|
- `decisions/scope.md` — development cascade, Phase 1 deliverable definition
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### #766 Cultural diversity sweep (largest ticket)
|
||||||
|
The cultural stereotyping audit (`docs/audits/cultural-stereotyping-audit.md`) flagged
|
||||||
|
multiple cultural groups with imbalanced representation. Afrikaans is the worst at ~15:1
|
||||||
|
confirming:subverting ratio (Sprint 30 added Brandpunt as first subversion). This ticket
|
||||||
|
requires reviewing all cultural groups, identifying imbalances, and rewriting wiki entries
|
||||||
|
to add subversions. Work across all 5 corridors. The audit's recommendations (tech hubs,
|
||||||
|
academic centers, commercial dynasties, artistic communities) should guide the rewrites.
|
||||||
|
|
||||||
|
**Scope rule:** This is a separate sprint's worth of work if done exhaustively. For this
|
||||||
|
sprint, focus on the top 3 most imbalanced groups and deliver concrete wiki rewrites for
|
||||||
|
at least 2 systems per group. File follow-up tickets for the remaining groups.
|
||||||
|
|
||||||
|
### #782 Per-body GTTR entries
|
||||||
|
Many systems have only system-level GTTR. Significant moons and stations (pop >10K) need
|
||||||
|
their own entries. Existing patterns: `wiki/star-systems/GJ-*/gttr.md`. Prioritize hub
|
||||||
|
systems at hops 1-4 first (highest traffic, most gameplay relevance).
|
||||||
|
|
||||||
|
### #774 Thin wiki entries
|
||||||
|
Paula's Sprint 30 review flagged Nova Estrada and Entremeio as noticeably thinner than
|
||||||
|
the batch standard. Enrich to match Kettenschmied-level quality (economic logic + human
|
||||||
|
cost in the same breath).
|
||||||
|
|
||||||
|
### #773 Iserlohn IP evaluation
|
||||||
|
Miri's review flagged "Iserlohn" (GJ-532 planet name) as a potential Legend of the Galactic
|
||||||
|
Heroes reference. The real-German-city rationale is documented in the proposal notes. Make a
|
||||||
|
deliberate decision: keep with rationale, or rename to another Sauerland-region reference.
|
||||||
|
|
||||||
|
## Dependency Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
#766 (cultural sweep) → standalone, largest piece
|
||||||
|
#782 (per-body GTTR) → standalone, parallel track
|
||||||
|
#774 (thin entries) → standalone, quick
|
||||||
|
#773 (Iserlohn IP) → standalone, quick decision
|
||||||
|
```
|
||||||
|
|
||||||
|
All tickets are independent — work in parallel by agent.
|
||||||
|
|
||||||
|
## PR Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||||
|
--title "data(copy): Sprint 31 — cultural sweep, GTTR depth, wiki quality" \
|
||||||
|
--description "body" --base main --head sprint-31/copy
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Sprint 31: Bedrock — Joint Notes
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## Sprint Completion Proof
|
||||||
|
|
||||||
|
When Sprint 31 is done, you can:
|
||||||
|
|
||||||
|
1. **Click any system** in the star map and see a popup with its name, star type, corridor, GTTR excerpt, body count, and adjacent systems
|
||||||
|
2. **Read culturally diverse wiki entries** — at least 2 subversion entries per top-3 imbalanced cultural groups
|
||||||
|
3. **Read GTTR entries for major moons and stations** — hub system bodies at hops 1-4 have individual entries
|
||||||
|
4. **Run `ruff check tooling/`** and get a clean pass
|
||||||
|
5. **Browse atlas.rs** and find subcommands in separate module files
|
||||||
|
6. **Browse main.gd** and find it under 15KB with responsibilities extracted
|
||||||
|
|
||||||
|
## Phase 1 Context
|
||||||
|
|
||||||
|
This sprint advances Phase 1 of the development cascade (epic #746). Phase 1
|
||||||
|
deliverable: "Implant-ready Godot map of the Reach with click-throughs + wiki/GTTR popups."
|
||||||
|
|
||||||
|
**What's done:** 301/301 systems, body catalogs, star map insert, GTTR coverage
|
||||||
|
**What this sprint adds:** Star map popups (#780), cultural quality (#766), GTTR depth (#782)
|
||||||
|
**What remains after this sprint:** Heightmaps (#778), planetary screenshots (#779), economic profiles (#781) — these need design discussion before implementation, targeted for Sprint 32.
|
||||||
|
|
||||||
|
## Not In Scope
|
||||||
|
|
||||||
|
The following are **Phase 2-6 work** and must NOT be started until Phase 1 is complete:
|
||||||
|
- Character creation production UI (#694)
|
||||||
|
- Location-to-culture resolution (#679)
|
||||||
|
- NPC personality surface area (#621)
|
||||||
|
- Tycoon starting state design (#615)
|
||||||
|
- Economic simulation
|
||||||
|
- Settlement generation
|
||||||
|
|
||||||
|
The cascade is enforced in the ticket system: #747 (Phase 2) is blocked by #746 (Phase 1).
|
||||||
|
|
||||||
|
## Refactor Rationale
|
||||||
|
|
||||||
|
The three refactors (#775, #776, #777) are infrastructure work, not feature development.
|
||||||
|
They reduce complexity that compounds every sprint:
|
||||||
|
- `atlas.rs` (2000+ lines) — every atlas feature adds to a single file
|
||||||
|
- `main.gd` (28KB) + `game_state.gd` (19KB) — every client feature touches these
|
||||||
|
- Python tooling (5 files with duplicated DB path logic) — every new script copies the pattern
|
||||||
|
|
||||||
|
Doing these now prevents the debt from growing further as Phase 1 content work continues.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Sprint 31: Bedrock — Retrospective
|
||||||
|
|
||||||
|
**Period:** 2026-04-05 (single day)
|
||||||
|
**Velocity:** 9/9 tickets completed (100%)
|
||||||
|
**PRs:** 4 merged (#110 copy, #111 server, #112 ci, #113 client)
|
||||||
|
|
||||||
|
## What Shipped
|
||||||
|
|
||||||
|
- **#766** Cultural diversity sweep — 10 systems enriched across 3 corridors, Afrikaans/Dutch subversions, per-body GTTR depth
|
||||||
|
- **#782** Per-body GTTR entries for 25 moons and stations at hub systems
|
||||||
|
- **#774** Nova Estrada and Entremeio enriched to batch standard
|
||||||
|
- **#773** D-168 Iserlohn IP evaluation — retained with documented rationale and Altena alternative on record
|
||||||
|
- **#780** Star map click-through popup with GTTR excerpt, edge-only-on-selected visibility rule
|
||||||
|
- **#775** main.gd decomposed from 28KB/644 lines to 13.4KB/322 lines (SnapshotConsumers, DialogueCoordinator, SnapshotHandler extracted)
|
||||||
|
- **#783** All 354 gdlint warnings resolved — zero warnings policy achieved
|
||||||
|
- **#776** atlas.rs split from 2119-line monolith into 8 focused modules
|
||||||
|
- **#777** Shared Python DB module (common.py), ruff linting, pre-push integration
|
||||||
|
|
||||||
|
## What Went Well
|
||||||
|
|
||||||
|
- **First sprint on ephemeral branches** — `start-sprint.sh` created worktrees, opened tabs, auto-ran `/sprint-start`. No cross-boundary issues, no stuck agents with uncommitted work, no stale index.lock collisions. The worktree migration paid off immediately.
|
||||||
|
- **Zero warnings achieved** — gdlint went from 354 warnings to 0, ruff is clean, clippy is clean. Pre-push now catches all three. Quality baseline established.
|
||||||
|
- **4 teams in parallel, same day** — copy, server, ci, client all delivered PRs within hours. The ephemeral branch model handled parallel execution cleanly.
|
||||||
|
- **Review cycles were fast** — server needed 2 rounds (changelog fix), ci and copy merged on first review, client merged on first review. No busywork rounds.
|
||||||
|
- **Process improvements shipped inline** — sprint-start process fix (team branches must not run pr-review) came in with the copy PR and merged naturally. Auto-unassign on sprint close fixed a recurring bookkeeping issue.
|
||||||
|
|
||||||
|
## What Hurt
|
||||||
|
|
||||||
|
- **SI agent died during sprint planning** — had to redo planning manually. The agent's death left a stale index.lock on main, which was the exact problem the worktree migration was designed to prevent. Root cause: SI was spawned in the main worktree, not an isolated one. Planning agents should use `isolation: "worktree"` in future.
|
||||||
|
- **Ptyxis tab placement** — first `start-sprint.sh` run opened 3 windows instead of tabs in the current window. Fixed quickly (switched `--new-window` to `--tab`), but the initial experience was messy.
|
||||||
|
- **Cargo.lock conflicts on merge** — server branch had a stale Cargo.lock from pre-v0.1.30. Required manual `git checkout` before merge. This will recur until sprint branches are created after the version bump.
|
||||||
|
|
||||||
|
## What We Learned
|
||||||
|
|
||||||
|
- **The cascade is load-bearing** — redirecting Sprint 31 from tycoon/character work back to Phase 1 wiki completion was the right call. The star map popup only works because 301 systems have GTTR content. Skipping ahead would have produced empty popups.
|
||||||
|
- **God object decomposition is safe in GDScript** — the autoload parse-order constraint was the main risk. The `load()` inline pattern works, and non-autoload classes can use `class_name` freely. Documented as convention in CLAUDE.md.
|
||||||
|
- **Copy PR review scope rule continues to work** — Paula's Fragua warning and Miri's GJ-1111 sync issue were correctly classified as content tickets, not PR blockers. Zero scope creep on copy reviews.
|
||||||
|
|
||||||
|
## Carry-Over
|
||||||
|
|
||||||
|
(none — 100% completion)
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
1. **Done:** Auto-unassign on sprint close added to `tooling/db/sprint stop` (committed)
|
||||||
|
2. **Done:** Zero-warnings policy added to pr-push and pr-review skills
|
||||||
|
3. **Done:** `start-sprint.sh` fixed to open tabs in current window
|
||||||
|
4. **Backlog:** Panel height bug in star map popup for hub systems (cosmetic — Hoshe flagged)
|
||||||
|
5. **Backlog:** `show.rs` SQL duplication maintenance trap (Tyre flagged — ticket-worthy)
|
||||||
|
6. **Backlog:** qdrant/audio connectors still have local `load_config()` — incomplete shared module adoption
|
||||||
|
7. **Future:** Planning agents should spawn with `isolation: "worktree"` to avoid main index.lock
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Sprint 31: Bedrock — Server 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/server`
|
||||||
|
**Agents:** Dudley (simulation), Tyre (architecture), Hoshe (QA)
|
||||||
|
|
||||||
|
## New Tickets
|
||||||
|
|
||||||
|
| # | Title | Blocked by |
|
||||||
|
|---|-------|------------|
|
||||||
|
| #776 | Split atlas.rs subcommands into modules | — |
|
||||||
|
|
||||||
|
Use `tooling/db/ticket show <id>` for full details.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- `decisions/architecture.md` — D-010 (server-authoritative), D-020 (Rust/bevy_ecs)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### #776 Split atlas.rs subcommands into modules (refactor)
|
||||||
|
`server/src/bin/atlas.rs` is 2000+ lines containing all CLI subcommands in one
|
||||||
|
file. Each subcommand (author, commit-system, corridor-status, list-systems,
|
||||||
|
verify, next, show-system, etc.) should be extracted into its own module.
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Create `server/src/bin/atlas/` module directory
|
||||||
|
- Move `atlas.rs` → `server/src/bin/atlas/main.rs` (thin dispatch)
|
||||||
|
- Extract each `cmd_*` function into its own file:
|
||||||
|
- `author.rs` — `cmd_author`, `generate_body_matrix`
|
||||||
|
- `commit.rs` — `cmd_commit_system`
|
||||||
|
- `corridor_status.rs` — `cmd_corridor_status`
|
||||||
|
- `list.rs` — `cmd_list_systems`, `cmd_next`, `cmd_unfinished`
|
||||||
|
- `show.rs` — `cmd_show_system`
|
||||||
|
- `verify.rs` — `cmd_verify`
|
||||||
|
- Shared types/helpers stay in `main.rs` or a `common.rs`
|
||||||
|
|
||||||
|
**Key file:** `server/src/bin/atlas.rs` (~2000 lines)
|
||||||
|
|
||||||
|
**Verify:** `cargo build --bin atlas` must compile. `cargo test` must pass.
|
||||||
|
Run `tooling/atlas stats` and `tooling/atlas corridor-status` to verify CLI
|
||||||
|
still works after the split.
|
||||||
|
|
||||||
|
## Dependency Chain
|
||||||
|
|
||||||
|
```
|
||||||
|
#776 (atlas split) → standalone
|
||||||
|
```
|
||||||
|
|
||||||
|
## PR Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||||
|
--title "refactor(simulation): Sprint 31 — split atlas.rs into modules" \
|
||||||
|
--description "body" --base main --head sprint-31/server
|
||||||
|
```
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
name: The Settled Reach
|
name: The Settled Reach
|
||||||
version: 0.1.30
|
version: 0.1.31
|
||||||
repository: settled-reach
|
repository: settled-reach
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[project]
|
||||||
|
name = "settled-reach-tooling"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"PyYAML",
|
||||||
|
"jsonschema",
|
||||||
|
"numpy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
# ruff 0.15.9 — checked clean against NVD + OSV, no CVEs on record (2026-04-05)
|
||||||
|
"ruff==0.15.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
# E9xx: Runtime syntax/encoding errors
|
||||||
|
# F401: Unused imports
|
||||||
|
# F811: Redefinition of unused name
|
||||||
|
# F821: Undefined name (catches missing imports like bare `os`)
|
||||||
|
select = ["E9", "F401", "F811", "F821"]
|
||||||
Generated
+1
-1
@@ -1236,7 +1236,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "settled-reach-server"
|
name = "settled-reach-server"
|
||||||
version = "0.1.29"
|
version = "0.1.30"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy_app",
|
"bevy_app",
|
||||||
"bevy_ecs",
|
"bevy_ecs",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "settled-reach-server"
|
name = "settled-reach-server"
|
||||||
version = "0.1.30"
|
version = "0.1.31"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,640 @@
|
|||||||
|
use std::process;
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Proposal types (owned by the author pipeline)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ProposalBody {
|
||||||
|
pub body_id: String,
|
||||||
|
pub proper_name: Option<String>,
|
||||||
|
pub body_type: String,
|
||||||
|
pub orbit_index: i32,
|
||||||
|
pub parent_body_id: Option<String>,
|
||||||
|
pub inhabited: bool,
|
||||||
|
pub population: Option<i64>,
|
||||||
|
pub mass_class: Option<String>,
|
||||||
|
pub surface_gravity: Option<f64>,
|
||||||
|
pub orbital_period_days: Option<f64>,
|
||||||
|
pub rotation_period_hours: Option<f64>,
|
||||||
|
pub atmosphere: Option<String>,
|
||||||
|
pub biome_summary: Option<String>,
|
||||||
|
pub hydrosphere: Option<String>,
|
||||||
|
pub economic_role: Option<String>,
|
||||||
|
pub settlement_pattern: Option<String>,
|
||||||
|
pub industrial_corridor: Option<String>,
|
||||||
|
pub notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ProposalStation {
|
||||||
|
pub station_id: String,
|
||||||
|
pub proper_name: Option<String>,
|
||||||
|
pub orbits_body_id: String,
|
||||||
|
pub station_type: String,
|
||||||
|
pub population: Option<i64>,
|
||||||
|
pub economic_role: Option<String>,
|
||||||
|
pub docking_class: Option<String>,
|
||||||
|
pub has_gate_infrastructure: bool,
|
||||||
|
pub notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct SystemProposal {
|
||||||
|
pub system_id: String,
|
||||||
|
pub proper_name: Option<String>,
|
||||||
|
pub star_type: Option<String>,
|
||||||
|
pub spectral_class: Option<String>,
|
||||||
|
pub wiki_data: WikiData,
|
||||||
|
pub bodies: Vec<ProposalBody>,
|
||||||
|
pub stations: Vec<ProposalStation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct WikiData {
|
||||||
|
pub habitable_count: i32,
|
||||||
|
pub inhabited_count: i32,
|
||||||
|
pub has_gas_giant: bool,
|
||||||
|
pub has_asteroid_belt: bool,
|
||||||
|
pub has_horizon_station: bool,
|
||||||
|
pub raw_bodies_line: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Author pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn parse_wiki_bodies_line(wiki_dir: &str, system_id: &str) -> (Option<String>, i32, i32) {
|
||||||
|
// Try to find the wiki page for this system
|
||||||
|
let sid_slug = system_id.replace(' ', "-");
|
||||||
|
let wiki_path = std::path::Path::new(wiki_dir)
|
||||||
|
.join(&sid_slug)
|
||||||
|
.join("index.md");
|
||||||
|
if !wiki_path.exists() {
|
||||||
|
return (None, 0, 0);
|
||||||
|
}
|
||||||
|
let content = std::fs::read_to_string(&wiki_path).unwrap_or_default();
|
||||||
|
// Look for "| **Bodies** | X habitable · Y inhabited |"
|
||||||
|
for line in content.lines() {
|
||||||
|
if line.contains("**Bodies**") {
|
||||||
|
let raw = line.to_string();
|
||||||
|
let mut hab = 0i32;
|
||||||
|
let mut inh = 0i32;
|
||||||
|
// Parse "N habitable"
|
||||||
|
if let Some(pos) = line.find("habitable") {
|
||||||
|
let before = &line[..pos];
|
||||||
|
let parts: Vec<&str> = before.split_whitespace().collect();
|
||||||
|
if let Some(n) = parts.last() {
|
||||||
|
hab = n.parse().unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Parse "N inhabited"
|
||||||
|
if let Some(pos) = line.find("inhabited") {
|
||||||
|
let before = &line[..pos];
|
||||||
|
let parts: Vec<&str> = before.split_whitespace().collect();
|
||||||
|
if let Some(n) = parts.last() {
|
||||||
|
inh = n.parse().unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (Some(raw), hab, inh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_body_matrix(
|
||||||
|
system_id: &str,
|
||||||
|
star_type: Option<&str>,
|
||||||
|
spectral: Option<&str>,
|
||||||
|
wiki_hab: i32,
|
||||||
|
wiki_inh: i32,
|
||||||
|
db_gas_giant: bool,
|
||||||
|
db_belt: bool,
|
||||||
|
has_horizon: bool,
|
||||||
|
) -> (Vec<ProposalBody>, Vec<ProposalStation>) {
|
||||||
|
let sid = system_id.replace(' ', "");
|
||||||
|
let mut bodies = Vec::new();
|
||||||
|
let mut stations = Vec::new();
|
||||||
|
let mut orbit = 1;
|
||||||
|
|
||||||
|
// Determine planet count from star type if wiki doesn't specify
|
||||||
|
let spectral_char = spectral.and_then(|s| s.chars().next()).unwrap_or('M');
|
||||||
|
|
||||||
|
// Base planet count by spectral type.
|
||||||
|
// Sol has 8. TRAPPIST-1 (M-dwarf) has 7. Minimum 6 for any star.
|
||||||
|
let total_planets = if wiki_hab > 0 || wiki_inh > 0 {
|
||||||
|
// Wiki has data — use it as the inhabited/habitable core, pad to realistic count
|
||||||
|
let known = wiki_hab.max(wiki_inh);
|
||||||
|
match spectral_char {
|
||||||
|
'O' | 'B' | 'A' => (known + 4).max(6), // hot stars — fewer but still 6+
|
||||||
|
'F' => (known + 5).max(8), // bright — wide system, 8+
|
||||||
|
'G' => (known + 5).max(8), // sol-like — 8 is baseline
|
||||||
|
'K' => (known + 4).max(7), // cooler — 7+ typical
|
||||||
|
'M' => (known + 4).max(6), // compact but TRAPPIST-1 has 7
|
||||||
|
_ => (known + 4).max(6),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No wiki data — generate realistic count
|
||||||
|
match spectral_char {
|
||||||
|
'O' | 'B' | 'A' => 6,
|
||||||
|
'F' => 8,
|
||||||
|
'G' => 8,
|
||||||
|
'K' => 7,
|
||||||
|
'M' => 6,
|
||||||
|
_ => 6,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine if binary — affects naming
|
||||||
|
let is_binary = star_type == Some("binary");
|
||||||
|
|
||||||
|
// Place inner barren rocky planets
|
||||||
|
let inner_barren = if total_planets > wiki_inh + 1 { 1 } else { 0 };
|
||||||
|
for _ in 0..inner_barren {
|
||||||
|
let letter = (b'b' + orbit as u8 - 1) as char;
|
||||||
|
let body_id = if is_binary {
|
||||||
|
format!("{}A{}", sid, letter) // circumbinary assumed for now
|
||||||
|
} else {
|
||||||
|
format!("{}{}", sid, letter)
|
||||||
|
};
|
||||||
|
bodies.push(ProposalBody {
|
||||||
|
body_id,
|
||||||
|
body_type: "planet".into(),
|
||||||
|
orbit_index: orbit,
|
||||||
|
parent_body_id: None,
|
||||||
|
inhabited: false,
|
||||||
|
proper_name: None,
|
||||||
|
population: None,
|
||||||
|
mass_class: Some("terrestrial".into()),
|
||||||
|
surface_gravity: Some(0.38),
|
||||||
|
orbital_period_days: Some(88.0),
|
||||||
|
rotation_period_hours: Some(1408.0),
|
||||||
|
atmosphere: Some("none".into()),
|
||||||
|
biome_summary: Some("barren".into()),
|
||||||
|
hydrosphere: None,
|
||||||
|
economic_role: None,
|
||||||
|
settlement_pattern: None,
|
||||||
|
industrial_corridor: None,
|
||||||
|
notes: "inner rocky, uninhabited".into(),
|
||||||
|
});
|
||||||
|
orbit += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place inhabited planets
|
||||||
|
for i in 0..wiki_inh {
|
||||||
|
let letter = (b'b' + orbit as u8 - 1) as char;
|
||||||
|
let body_id = if is_binary {
|
||||||
|
format!("{}A{}", sid, letter)
|
||||||
|
} else {
|
||||||
|
format!("{}{}", sid, letter)
|
||||||
|
};
|
||||||
|
bodies.push(ProposalBody {
|
||||||
|
body_id,
|
||||||
|
body_type: "planet".into(),
|
||||||
|
orbit_index: orbit,
|
||||||
|
parent_body_id: None,
|
||||||
|
inhabited: true,
|
||||||
|
proper_name: None,
|
||||||
|
population: None,
|
||||||
|
mass_class: Some("terrestrial".into()),
|
||||||
|
surface_gravity: Some(0.9),
|
||||||
|
orbital_period_days: Some(365.0),
|
||||||
|
rotation_period_hours: Some(24.0),
|
||||||
|
atmosphere: Some("standard".into()),
|
||||||
|
biome_summary: Some("temperate".into()),
|
||||||
|
hydrosphere: None,
|
||||||
|
economic_role: None,
|
||||||
|
settlement_pattern: None,
|
||||||
|
industrial_corridor: None,
|
||||||
|
notes: format!("inhabited planet {}/{}", i + 1, wiki_inh),
|
||||||
|
});
|
||||||
|
orbit += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place habitable-but-uninhabited planets (hab > inh)
|
||||||
|
let hab_only = (wiki_hab - wiki_inh).max(0);
|
||||||
|
for _ in 0..hab_only {
|
||||||
|
let letter = (b'b' + orbit as u8 - 1) as char;
|
||||||
|
let body_id = if is_binary {
|
||||||
|
format!("{}A{}", sid, letter)
|
||||||
|
} else {
|
||||||
|
format!("{}{}", sid, letter)
|
||||||
|
};
|
||||||
|
bodies.push(ProposalBody {
|
||||||
|
body_id,
|
||||||
|
body_type: "planet".into(),
|
||||||
|
orbit_index: orbit,
|
||||||
|
parent_body_id: None,
|
||||||
|
inhabited: false,
|
||||||
|
proper_name: None,
|
||||||
|
population: None,
|
||||||
|
mass_class: Some("terrestrial".into()),
|
||||||
|
surface_gravity: Some(0.85),
|
||||||
|
orbital_period_days: Some(400.0),
|
||||||
|
rotation_period_hours: Some(26.0),
|
||||||
|
atmosphere: Some("standard".into()),
|
||||||
|
biome_summary: Some("temperate".into()),
|
||||||
|
hydrosphere: None,
|
||||||
|
economic_role: None,
|
||||||
|
settlement_pattern: None,
|
||||||
|
industrial_corridor: None,
|
||||||
|
notes: "habitable, uninhabited".into(),
|
||||||
|
});
|
||||||
|
orbit += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outer rocky/ice planets to fill remaining count
|
||||||
|
let placed = inner_barren + wiki_inh + hab_only;
|
||||||
|
let remaining_planets = (total_planets - placed).max(0);
|
||||||
|
for _ in 0..remaining_planets {
|
||||||
|
let letter = (b'b' + orbit as u8 - 1) as char;
|
||||||
|
let body_id = if is_binary {
|
||||||
|
format!("{}A{}", sid, letter)
|
||||||
|
} else {
|
||||||
|
format!("{}{}", sid, letter)
|
||||||
|
};
|
||||||
|
bodies.push(ProposalBody {
|
||||||
|
body_id,
|
||||||
|
body_type: "planet".into(),
|
||||||
|
orbit_index: orbit,
|
||||||
|
parent_body_id: None,
|
||||||
|
inhabited: false,
|
||||||
|
proper_name: None,
|
||||||
|
population: None,
|
||||||
|
mass_class: Some("terrestrial".into()),
|
||||||
|
surface_gravity: Some(0.5),
|
||||||
|
orbital_period_days: Some(2000.0),
|
||||||
|
rotation_period_hours: Some(18.0),
|
||||||
|
atmosphere: Some("thin".into()),
|
||||||
|
biome_summary: Some("frozen".into()),
|
||||||
|
hydrosphere: None,
|
||||||
|
economic_role: None,
|
||||||
|
settlement_pattern: None,
|
||||||
|
industrial_corridor: None,
|
||||||
|
notes: "outer rocky/ice, uninhabited".into(),
|
||||||
|
});
|
||||||
|
orbit += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asteroid belt
|
||||||
|
if db_belt {
|
||||||
|
bodies.push(ProposalBody {
|
||||||
|
body_id: format!("{}-belt", sid),
|
||||||
|
body_type: "asteroid_belt".into(),
|
||||||
|
orbit_index: orbit,
|
||||||
|
parent_body_id: None,
|
||||||
|
inhabited: false,
|
||||||
|
proper_name: None,
|
||||||
|
population: None,
|
||||||
|
mass_class: None,
|
||||||
|
surface_gravity: None,
|
||||||
|
orbital_period_days: None,
|
||||||
|
rotation_period_hours: None,
|
||||||
|
atmosphere: None,
|
||||||
|
biome_summary: None,
|
||||||
|
hydrosphere: None,
|
||||||
|
economic_role: None,
|
||||||
|
settlement_pattern: None,
|
||||||
|
industrial_corridor: None,
|
||||||
|
notes: "asteroid belt".into(),
|
||||||
|
});
|
||||||
|
orbit += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gas giant (with up to 2 moons as default)
|
||||||
|
if db_gas_giant {
|
||||||
|
let letter = (b'b' + orbit as u8 - 1) as char;
|
||||||
|
let gg_id = if is_binary {
|
||||||
|
format!("{}A{}", sid, letter)
|
||||||
|
} else {
|
||||||
|
format!("{}{}", sid, letter)
|
||||||
|
};
|
||||||
|
bodies.push(ProposalBody {
|
||||||
|
body_id: gg_id.clone(),
|
||||||
|
body_type: "gas_giant".into(),
|
||||||
|
orbit_index: orbit,
|
||||||
|
parent_body_id: None,
|
||||||
|
inhabited: false,
|
||||||
|
proper_name: None,
|
||||||
|
population: None,
|
||||||
|
mass_class: Some("gas_giant".into()),
|
||||||
|
surface_gravity: None,
|
||||||
|
orbital_period_days: Some(4300.0),
|
||||||
|
rotation_period_hours: Some(10.0),
|
||||||
|
atmosphere: Some("dense".into()),
|
||||||
|
biome_summary: None,
|
||||||
|
hydrosphere: None,
|
||||||
|
economic_role: None,
|
||||||
|
settlement_pattern: None,
|
||||||
|
industrial_corridor: None,
|
||||||
|
notes: "gas giant".into(),
|
||||||
|
});
|
||||||
|
// 2 default moons
|
||||||
|
for m in 1..=2 {
|
||||||
|
bodies.push(ProposalBody {
|
||||||
|
body_id: format!("{}-{}", gg_id, m),
|
||||||
|
body_type: "moon".into(),
|
||||||
|
orbit_index: m,
|
||||||
|
parent_body_id: Some(gg_id.clone()),
|
||||||
|
inhabited: false,
|
||||||
|
proper_name: None,
|
||||||
|
population: None,
|
||||||
|
mass_class: Some("dwarf".into()),
|
||||||
|
surface_gravity: Some(0.1),
|
||||||
|
orbital_period_days: Some(3.5 * m as f64),
|
||||||
|
rotation_period_hours: Some(3.5 * 24.0 * m as f64),
|
||||||
|
atmosphere: Some("none".into()),
|
||||||
|
biome_summary: Some("barren".into()),
|
||||||
|
hydrosphere: None,
|
||||||
|
economic_role: None,
|
||||||
|
settlement_pattern: None,
|
||||||
|
industrial_corridor: None,
|
||||||
|
notes: format!("moon {} of gas giant", m),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
orbit += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oort cloud (always)
|
||||||
|
let oort_id = format!("{}-oort", sid);
|
||||||
|
bodies.push(ProposalBody {
|
||||||
|
body_id: oort_id.clone(),
|
||||||
|
body_type: "oort_cloud".into(),
|
||||||
|
orbit_index: orbit,
|
||||||
|
parent_body_id: None,
|
||||||
|
inhabited: false,
|
||||||
|
proper_name: None,
|
||||||
|
population: None,
|
||||||
|
mass_class: None,
|
||||||
|
surface_gravity: None,
|
||||||
|
orbital_period_days: None,
|
||||||
|
rotation_period_hours: None,
|
||||||
|
atmosphere: None,
|
||||||
|
biome_summary: None,
|
||||||
|
hydrosphere: None,
|
||||||
|
economic_role: None,
|
||||||
|
settlement_pattern: None,
|
||||||
|
industrial_corridor: None,
|
||||||
|
notes: "oort cloud".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Horizon station
|
||||||
|
if has_horizon {
|
||||||
|
stations.push(ProposalStation {
|
||||||
|
station_id: format!("{}-oort-S1", sid),
|
||||||
|
proper_name: None,
|
||||||
|
orbits_body_id: oort_id,
|
||||||
|
station_type: "horizon".into(),
|
||||||
|
population: None,
|
||||||
|
economic_role: Some("transit".into()),
|
||||||
|
docking_class: Some("major".into()),
|
||||||
|
has_gate_infrastructure: true,
|
||||||
|
notes: "horizon station — gate infrastructure".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(bodies, stations)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_author(conn: &Connection, system_id: &str, outdir: &str, wiki_dir: &str) {
|
||||||
|
// Get system data from DB
|
||||||
|
let sys = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT s.system_id, s.proper_name, s.star_type, s.spectral_class,
|
||||||
|
s.habitable_planet_count, s.inhabited_planet_count,
|
||||||
|
s.asteroid_belt, s.gas_giant,
|
||||||
|
g.horizon_station
|
||||||
|
FROM star_systems s
|
||||||
|
LEFT JOIN system_gates g ON s.system_id = g.system_id
|
||||||
|
WHERE s.system_id = ?1",
|
||||||
|
params![system_id],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, Option<String>>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
|
row.get::<_, Option<i32>>(4)?,
|
||||||
|
row.get::<_, Option<i32>>(5)?,
|
||||||
|
row.get::<_, Option<i32>>(6)?,
|
||||||
|
row.get::<_, Option<i32>>(7)?,
|
||||||
|
row.get::<_, Option<i32>>(8)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
eprintln!("error: system '{}' not found", system_id);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
let (sid, proper_name, star_type, spectral, db_hab, db_inh, db_belt, db_gg, db_horizon) = sys;
|
||||||
|
|
||||||
|
// Parse wiki for body info
|
||||||
|
let (raw_line, wiki_hab, wiki_inh) = parse_wiki_bodies_line(wiki_dir, &sid);
|
||||||
|
|
||||||
|
// Use wiki data preferentially, fall back to DB
|
||||||
|
let hab = if wiki_hab > 0 {
|
||||||
|
wiki_hab
|
||||||
|
} else {
|
||||||
|
db_hab.unwrap_or(0)
|
||||||
|
};
|
||||||
|
let inh = if wiki_inh > 0 {
|
||||||
|
wiki_inh
|
||||||
|
} else {
|
||||||
|
db_inh.unwrap_or(0)
|
||||||
|
};
|
||||||
|
let has_belt = db_belt.unwrap_or(0) != 0;
|
||||||
|
let has_gg = db_gg.unwrap_or(0) != 0;
|
||||||
|
let has_horizon = db_horizon.unwrap_or(0) != 0;
|
||||||
|
|
||||||
|
let wiki_data = WikiData {
|
||||||
|
habitable_count: hab,
|
||||||
|
inhabited_count: inh,
|
||||||
|
has_gas_giant: has_gg,
|
||||||
|
has_asteroid_belt: has_belt,
|
||||||
|
has_horizon_station: has_horizon,
|
||||||
|
raw_bodies_line: raw_line,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (bodies, stations) = generate_body_matrix(
|
||||||
|
&sid,
|
||||||
|
star_type.as_deref(),
|
||||||
|
spectral.as_deref(),
|
||||||
|
hab,
|
||||||
|
inh,
|
||||||
|
has_gg,
|
||||||
|
has_belt,
|
||||||
|
has_horizon,
|
||||||
|
);
|
||||||
|
|
||||||
|
let proposal = SystemProposal {
|
||||||
|
system_id: sid.clone(),
|
||||||
|
proper_name,
|
||||||
|
star_type,
|
||||||
|
spectral_class: spectral,
|
||||||
|
wiki_data,
|
||||||
|
bodies,
|
||||||
|
stations,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write proposal JSON
|
||||||
|
let sid_compact = sid.replace(' ', "");
|
||||||
|
std::fs::create_dir_all(outdir).unwrap();
|
||||||
|
let path = std::path::Path::new(outdir).join(format!("{}.json", sid_compact));
|
||||||
|
let json = serde_json::to_string_pretty(&proposal).unwrap();
|
||||||
|
std::fs::write(&path, &json).unwrap();
|
||||||
|
|
||||||
|
eprintln!("Proposal written to {}", path.display());
|
||||||
|
println!("{}", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_commit_system(conn: &Connection, path: &str) {
|
||||||
|
let content = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error: cannot read {}: {}", path, e);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
let proposal: SystemProposal = serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error: invalid proposal JSON: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for existing bodies
|
||||||
|
let existing: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM bodies WHERE system_id = ?1",
|
||||||
|
params![proposal.system_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
if existing > 0 {
|
||||||
|
eprintln!(
|
||||||
|
"error: system '{}' already has {} bodies. Use wipe-system first.",
|
||||||
|
proposal.system_id, existing
|
||||||
|
);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx = conn.unchecked_transaction().unwrap();
|
||||||
|
|
||||||
|
for body in &proposal.bodies {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO bodies (body_id, system_id, parent_body_id, body_type, orbit_index,
|
||||||
|
proper_name, inhabited, population, mass_class, surface_gravity,
|
||||||
|
orbital_period_days, rotation_period_hours, atmosphere,
|
||||||
|
biome_summary, hydrosphere, economic_role, settlement_pattern,
|
||||||
|
industrial_corridor)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||||
|
params![
|
||||||
|
body.body_id,
|
||||||
|
proposal.system_id,
|
||||||
|
body.parent_body_id,
|
||||||
|
body.body_type,
|
||||||
|
body.orbit_index,
|
||||||
|
body.proper_name,
|
||||||
|
body.inhabited as i32,
|
||||||
|
body.population.unwrap_or(0),
|
||||||
|
body.mass_class,
|
||||||
|
body.surface_gravity,
|
||||||
|
body.orbital_period_days,
|
||||||
|
body.rotation_period_hours,
|
||||||
|
body.atmosphere,
|
||||||
|
body.biome_summary,
|
||||||
|
body.hydrosphere,
|
||||||
|
body.economic_role,
|
||||||
|
body.settlement_pattern,
|
||||||
|
body.industrial_corridor,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!("error inserting body '{}': {}", body.body_id, e);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for station in &proposal.stations {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO stations (station_id, system_id, orbits_body_id, station_type,
|
||||||
|
proper_name, population, economic_role, docking_class,
|
||||||
|
has_gate_infrastructure)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
params![
|
||||||
|
station.station_id,
|
||||||
|
proposal.system_id,
|
||||||
|
station.orbits_body_id,
|
||||||
|
station.station_type,
|
||||||
|
station.proper_name,
|
||||||
|
station.population.unwrap_or(0),
|
||||||
|
station.economic_role,
|
||||||
|
station.docking_class,
|
||||||
|
station.has_gate_infrastructure as i32,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!("error inserting station '{}': {}", station.station_id, e);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update star_systems counts from the actual body data
|
||||||
|
let habitable_count: i32 = proposal
|
||||||
|
.bodies
|
||||||
|
.iter()
|
||||||
|
.filter(|b| {
|
||||||
|
matches!(
|
||||||
|
b.atmosphere.as_deref(),
|
||||||
|
Some("breathable") | Some("standard")
|
||||||
|
) && (b.body_type == "planet" || b.body_type == "moon")
|
||||||
|
})
|
||||||
|
.count() as i32;
|
||||||
|
|
||||||
|
let inhabited_count: i32 = proposal.bodies.iter().filter(|b| b.inhabited).count() as i32
|
||||||
|
+ proposal
|
||||||
|
.stations
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.population.unwrap_or(0) > 0 || s.station_type == "horizon")
|
||||||
|
.count() as i32;
|
||||||
|
|
||||||
|
let has_gas_giant: i32 = proposal.bodies.iter().any(|b| b.body_type == "gas_giant") as i32;
|
||||||
|
|
||||||
|
let has_belt: i32 = proposal
|
||||||
|
.bodies
|
||||||
|
.iter()
|
||||||
|
.any(|b| b.body_type == "asteroid_belt") as i32;
|
||||||
|
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE star_systems SET habitable_planet_count = ?1, inhabited_planet_count = ?2,
|
||||||
|
gas_giant = ?3, asteroid_belt = ?4
|
||||||
|
WHERE system_id = ?5",
|
||||||
|
params![
|
||||||
|
habitable_count,
|
||||||
|
inhabited_count,
|
||||||
|
has_gas_giant,
|
||||||
|
has_belt,
|
||||||
|
proposal.system_id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tx.commit().unwrap();
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CommitResult {
|
||||||
|
system_id: String,
|
||||||
|
bodies_created: usize,
|
||||||
|
stations_created: usize,
|
||||||
|
habitable_count: i32,
|
||||||
|
inhabited_count: i32,
|
||||||
|
}
|
||||||
|
let result = CommitResult {
|
||||||
|
system_id: proposal.system_id,
|
||||||
|
bodies_created: proposal.bodies.len(),
|
||||||
|
stations_created: proposal.stations.len(),
|
||||||
|
habitable_count,
|
||||||
|
inhabited_count,
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string_pretty(&result).unwrap());
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared output types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct BodyRow {
|
||||||
|
pub body_id: String,
|
||||||
|
pub system_id: String,
|
||||||
|
pub parent_body_id: Option<String>,
|
||||||
|
pub body_type: String,
|
||||||
|
pub orbit_index: Option<i32>,
|
||||||
|
pub proper_name: Option<String>,
|
||||||
|
pub mass_class: Option<String>,
|
||||||
|
pub atmosphere: Option<String>,
|
||||||
|
pub surface_gravity: Option<f64>,
|
||||||
|
pub biome_summary: Option<String>,
|
||||||
|
pub hydrosphere: Option<String>,
|
||||||
|
pub inhabited: bool,
|
||||||
|
pub population: i64,
|
||||||
|
pub economic_role: Option<String>,
|
||||||
|
pub cultural_corridor: Option<String>,
|
||||||
|
pub industrial_corridor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StationRow {
|
||||||
|
pub station_id: String,
|
||||||
|
pub system_id: String,
|
||||||
|
pub orbits_body_id: Option<String>,
|
||||||
|
pub station_type: String,
|
||||||
|
pub proper_name: Option<String>,
|
||||||
|
pub population: i64,
|
||||||
|
pub economic_role: Option<String>,
|
||||||
|
pub governance_type: Option<String>,
|
||||||
|
pub docking_class: Option<String>,
|
||||||
|
pub has_gate_infrastructure: bool,
|
||||||
|
pub district_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SystemSummary {
|
||||||
|
pub system_id: String,
|
||||||
|
pub proper_name: Option<String>,
|
||||||
|
pub star_type: Option<String>,
|
||||||
|
pub geographic_sector: Option<String>,
|
||||||
|
pub habitable_planet_count: Option<i32>,
|
||||||
|
pub inhabited_planet_count: Option<i32>,
|
||||||
|
pub bodies: Vec<BodyRow>,
|
||||||
|
pub stations: Vec<StationRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Database helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn resolve_db_path(explicit: Option<PathBuf>) -> PathBuf {
|
||||||
|
if let Some(p) = explicit {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
// Walk up from CWD looking for server/data/systems.db
|
||||||
|
let mut dir = std::env::current_dir().expect("Cannot determine CWD");
|
||||||
|
loop {
|
||||||
|
let candidate = dir.join("server").join("data").join("systems.db");
|
||||||
|
if candidate.exists() {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
if !dir.pop() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!("error: cannot find server/data/systems.db — pass --db explicitly");
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_db(path: &PathBuf) -> Connection {
|
||||||
|
let conn = Connection::open(path).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error: cannot open {}: {}", path.display(), e);
|
||||||
|
process::exit(1);
|
||||||
|
});
|
||||||
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
|
||||||
|
.unwrap();
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared query helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn query_bodies(
|
||||||
|
conn: &Connection,
|
||||||
|
system: Option<&str>,
|
||||||
|
body_type: Option<&str>,
|
||||||
|
inhabited_only: bool,
|
||||||
|
unnamed_only: bool,
|
||||||
|
) -> Vec<BodyRow> {
|
||||||
|
let mut sql = String::from(
|
||||||
|
"SELECT body_id, system_id, parent_body_id, body_type, orbit_index,
|
||||||
|
proper_name, mass_class, atmosphere, surface_gravity,
|
||||||
|
biome_summary, hydrosphere, inhabited, population,
|
||||||
|
economic_role, cultural_corridor, industrial_corridor
|
||||||
|
FROM bodies WHERE 1=1",
|
||||||
|
);
|
||||||
|
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(s) = system {
|
||||||
|
sql.push_str(" AND system_id = ?");
|
||||||
|
param_values.push(Box::new(s.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(t) = body_type {
|
||||||
|
sql.push_str(" AND body_type = ?");
|
||||||
|
param_values.push(Box::new(t.to_string()));
|
||||||
|
}
|
||||||
|
if inhabited_only {
|
||||||
|
sql.push_str(" AND inhabited = 1");
|
||||||
|
}
|
||||||
|
if unnamed_only {
|
||||||
|
sql.push_str(" AND proper_name IS NULL");
|
||||||
|
}
|
||||||
|
sql.push_str(" ORDER BY system_id, orbit_index");
|
||||||
|
|
||||||
|
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
|
||||||
|
param_values.iter().map(|p| p.as_ref()).collect();
|
||||||
|
let mut stmt = conn.prepare(&sql).unwrap();
|
||||||
|
stmt.query_map(params_ref.as_slice(), |row| {
|
||||||
|
Ok(BodyRow {
|
||||||
|
body_id: row.get(0)?,
|
||||||
|
system_id: row.get(1)?,
|
||||||
|
parent_body_id: row.get(2)?,
|
||||||
|
body_type: row.get(3)?,
|
||||||
|
orbit_index: row.get(4)?,
|
||||||
|
proper_name: row.get(5)?,
|
||||||
|
mass_class: row.get(6)?,
|
||||||
|
atmosphere: row.get(7)?,
|
||||||
|
surface_gravity: row.get(8)?,
|
||||||
|
biome_summary: row.get(9)?,
|
||||||
|
hydrosphere: row.get(10)?,
|
||||||
|
inhabited: row.get::<_, i32>(11)? != 0,
|
||||||
|
population: row.get::<_, Option<i64>>(12)?.unwrap_or(0),
|
||||||
|
economic_role: row.get(13)?,
|
||||||
|
cultural_corridor: row.get(14)?,
|
||||||
|
industrial_corridor: row.get(15)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_stations(
|
||||||
|
conn: &Connection,
|
||||||
|
system: Option<&str>,
|
||||||
|
station_type: Option<&str>,
|
||||||
|
) -> Vec<StationRow> {
|
||||||
|
let mut sql = String::from(
|
||||||
|
"SELECT station_id, system_id, orbits_body_id, station_type,
|
||||||
|
proper_name, population, economic_role, governance_type,
|
||||||
|
docking_class, has_gate_infrastructure, district_count
|
||||||
|
FROM stations WHERE 1=1",
|
||||||
|
);
|
||||||
|
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(s) = system {
|
||||||
|
sql.push_str(" AND system_id = ?");
|
||||||
|
param_values.push(Box::new(s.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(t) = station_type {
|
||||||
|
sql.push_str(" AND station_type = ?");
|
||||||
|
param_values.push(Box::new(t.to_string()));
|
||||||
|
}
|
||||||
|
sql.push_str(" ORDER BY system_id, station_id");
|
||||||
|
|
||||||
|
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
|
||||||
|
param_values.iter().map(|p| p.as_ref()).collect();
|
||||||
|
let mut stmt = conn.prepare(&sql).unwrap();
|
||||||
|
stmt.query_map(params_ref.as_slice(), |row| {
|
||||||
|
Ok(StationRow {
|
||||||
|
station_id: row.get(0)?,
|
||||||
|
system_id: row.get(1)?,
|
||||||
|
orbits_body_id: row.get(2)?,
|
||||||
|
station_type: row.get(3)?,
|
||||||
|
proper_name: row.get(4)?,
|
||||||
|
population: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
|
||||||
|
economic_role: row.get(6)?,
|
||||||
|
governance_type: row.get(7)?,
|
||||||
|
docking_class: row.get(8)?,
|
||||||
|
has_gate_infrastructure: row.get::<_, Option<i32>>(9)?.unwrap_or(0) != 0,
|
||||||
|
district_count: row.get::<_, Option<i32>>(10)?.unwrap_or(1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query_stations_for_body(conn: &Connection, body_id: &str) -> Vec<StationRow> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT station_id, system_id, orbits_body_id, station_type,
|
||||||
|
proper_name, population, economic_role, governance_type,
|
||||||
|
docking_class, has_gate_infrastructure, district_count
|
||||||
|
FROM stations WHERE orbits_body_id = ?1 ORDER BY station_id",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
stmt.query_map(params![body_id], |row| {
|
||||||
|
Ok(StationRow {
|
||||||
|
station_id: row.get(0)?,
|
||||||
|
system_id: row.get(1)?,
|
||||||
|
orbits_body_id: row.get(2)?,
|
||||||
|
station_type: row.get(3)?,
|
||||||
|
proper_name: row.get(4)?,
|
||||||
|
population: row.get::<_, Option<i64>>(5)?.unwrap_or(0),
|
||||||
|
economic_role: row.get(6)?,
|
||||||
|
governance_type: row.get(7)?,
|
||||||
|
docking_class: row.get(8)?,
|
||||||
|
has_gate_infrastructure: row.get::<_, Option<i32>>(9)?.unwrap_or(0) != 0,
|
||||||
|
district_count: row.get::<_, Option<i32>>(10)?.unwrap_or(1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn format_population(pop: i64) -> String {
|
||||||
|
if pop >= 1_000_000_000 {
|
||||||
|
format!("{:.1}B", pop as f64 / 1_000_000_000.0)
|
||||||
|
} else if pop >= 1_000_000 {
|
||||||
|
format!("{}M", pop / 1_000_000)
|
||||||
|
} else if pop >= 1_000 {
|
||||||
|
format!("{}K", pop / 1_000)
|
||||||
|
} else {
|
||||||
|
format!("{}", pop)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user