Compare commits
@@ -0,0 +1,188 @@
|
||||
# Asset Pipeline — Source-Canonical Rule
|
||||
|
||||
`server/data/systems.db` is a **read-only, deterministic snapshot** produced by the
|
||||
generator pipeline. It is checked in to the repo as a build artefact so the Godot
|
||||
client can ship it without a build step, but **it is never the source of truth**.
|
||||
|
||||
---
|
||||
|
||||
## The Golden Rule
|
||||
|
||||
> **Edit sources, not the DB.**
|
||||
|
||||
If you need to change economics data, modify the TOML/JSON source files.
|
||||
If you need to change atlas markers, modify the `markers.json` files.
|
||||
Never run `UPDATE` or `INSERT` directly on `server/data/systems.db` outside of a
|
||||
migration — those changes will be silently overwritten by the next `make regen-db`.
|
||||
|
||||
---
|
||||
|
||||
## What produces systems.db
|
||||
|
||||
Two generators write to `systems.db`:
|
||||
|
||||
| Generator | Command | Source files (all contribute to the meta stamp SHA) |
|
||||
|-----------|---------|--------------|
|
||||
| `import_economics` | `python3 tooling/economy-db/import_economics.py` | `tooling/economy-db/import_economics.py` + the Rust brand binary sources it invokes: `server/src/bin/generate_brands/main.rs`, `server/src/bin/generate_brands/names.rs`, `tooling/generate-brands` + shared `tooling/schema_version.py` |
|
||||
| `generate_atlas` | `python3 tooling/planet-gen/generate_atlas.py --seed 42` | `tooling/planet-gen/generate_atlas.py` + shared `tooling/schema_version.py` |
|
||||
|
||||
`import_economics` shells out to the Rust `generate_brands` binary as its first
|
||||
step to refresh `wiki/economics/corporations/generated_brands.toml`, then reads
|
||||
the TOML and imports brand data into the DB. The Rust binary is a subroutine
|
||||
of the Python importer, not an independent generator — changes to its source
|
||||
invalidate the `import_economics` meta stamp even though the Python file
|
||||
itself didn't change.
|
||||
|
||||
`make regen-db` runs both in the correct order (economics first, atlas second).
|
||||
|
||||
---
|
||||
|
||||
## The meta table stamp (#855, #856)
|
||||
|
||||
After every successful non-dry-run, each generator writes a row to the `meta` table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE meta (
|
||||
generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas'
|
||||
schema_version TEXT NOT NULL, -- monotonic semver string (e.g. "1.0.0") — see #888
|
||||
schema_sha TEXT, -- SHA-1 of server/data/systems-schema.sql (tamper detection)
|
||||
generator_sha TEXT NOT NULL, -- SHA-1 of the generator source file(s)
|
||||
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
`schema_version` is a **monotonic semver string** (e.g. `"1.0.0"`), not a hash.
|
||||
It is defined as the `SCHEMA_VERSION` constant in `tooling/schema_version.py`
|
||||
and must be bumped manually whenever the schema changes in a backwards-incompatible way.
|
||||
Unlike a SHA-1 hash, semver strings are orderable — this enables savegame migration
|
||||
lineage in Phase 5+: a save file can record which schema version it derives from and
|
||||
determine exactly which migrations to apply (#888). The old SHA-1 is preserved in
|
||||
`schema_sha` for tamper detection alongside the semver.
|
||||
|
||||
The `generator_sha` is the SHA-1 of the concatenated bytes of the generator's
|
||||
source files (sorted by path, so order is deterministic). If any source file
|
||||
changes and `make regen-db` is not re-run, the stamped SHA will differ from the
|
||||
recomputed current SHA — this is what the pre-push hook detects.
|
||||
|
||||
**What's deterministic:** the stored SHA (same sources → same recorded SHA).
|
||||
**What's NOT deterministic:** the DB binary itself. `meta.generated_at` uses
|
||||
`datetime('now')`, SQLite `rowid`/`autoincrement` values drift across runs, and
|
||||
transaction ordering can reshape freelist pages — two consecutive `make regen-db`
|
||||
calls produce byte-different SQLite files even with identical inputs. This is
|
||||
fine: the freshness guarantee comes from the stamp, not from bytewise DB equality.
|
||||
|
||||
---
|
||||
|
||||
## How to make a DB change
|
||||
|
||||
### Normal data changes (economics, atlas markers)
|
||||
|
||||
1. Edit the source files (TOML, JSON, markers.json).
|
||||
2. Run `make regen-db`.
|
||||
3. Run `make check-systems-db` to confirm the stamp is fresh.
|
||||
4. Stage and commit:
|
||||
```bash
|
||||
git add server/data/systems.db
|
||||
git commit -m "chore(db): regen systems.db — <what changed>"
|
||||
```
|
||||
|
||||
### Schema changes (new tables or columns)
|
||||
|
||||
1. Add the DDL to `server/data/systems-schema.sql`.
|
||||
2. Add migration SQL to `MIGRATION_SQL` in `import_economics.py` if the change
|
||||
affects existing DBs (idempotent `CREATE TABLE IF NOT EXISTS` or `ALTER TABLE`).
|
||||
3. Run `make regen-db`.
|
||||
4. Stage `server/data/systems-schema.sql` and `server/data/systems.db` together.
|
||||
|
||||
---
|
||||
|
||||
## Pre-push hook (#857)
|
||||
|
||||
`.config/hooks/pre-push` (installed via `make install-hooks`) checks that whenever
|
||||
`server/data/systems.db` is in the push, its meta stamp matches the current generator
|
||||
source SHAs. If not, the push is rejected with:
|
||||
|
||||
```
|
||||
systems.db is stale — run `make regen-db` before pushing.
|
||||
Stale generators: ['import_economics']
|
||||
```
|
||||
|
||||
Fix: run `make regen-db`, stage `server/data/systems.db`, amend or add a commit.
|
||||
Or use `/pr-push` — it detects stale generator sources and reruns `make regen-db`
|
||||
automatically before pushing.
|
||||
|
||||
The check script is `tooling/check-systems-db-stamp`. Run it interactively with
|
||||
`make check-systems-db` or `python3 tooling/check-systems-db-stamp --verbose`. The
|
||||
`GENERATOR_SOURCES` dict at the top of that script is the single registry — when
|
||||
you add a new generator or source file, update it there and mirror the change in
|
||||
the `/pr-push` skill's source-file watch list.
|
||||
|
||||
---
|
||||
|
||||
## /pr-push integration (#858)
|
||||
|
||||
The `/pr-push` skill checks whether any generator source files are modified on the
|
||||
branch. If they are, it automatically runs `make regen-db` and stages the updated
|
||||
`server/data/systems.db` before pushing — preventing pre-push hook rejections on
|
||||
branches that modify generators without regenerating.
|
||||
|
||||
---
|
||||
|
||||
## Why direct DB edits are forbidden
|
||||
|
||||
Two branches that both commit `server/data/systems.db` changes produce a binary
|
||||
merge conflict. Git cannot diff or merge binary SQLite files. Sprint 36 hit this
|
||||
exact class of problem. The meta stamp + pre-push hook is the systematic fix:
|
||||
|
||||
- The stamp is deterministic (same generator source → same recorded SHA)
|
||||
- Only one branch modifies generator sources at a time (per team scope rules)
|
||||
- The pre-push hook is a hard blocker before the binary conflict can land
|
||||
|
||||
## The migration escape hatch
|
||||
|
||||
The rule above says "never run UPDATE or INSERT directly on systems.db outside
|
||||
of a migration." Here's what a legitimate migration looks like, and what isn't
|
||||
one:
|
||||
|
||||
**Sanctioned path: the `MIGRATION_SQL` block in `import_economics.py`.** That
|
||||
string is executed at the top of every import run (inside the same transaction
|
||||
that clears + reimports data) and contains idempotent `CREATE TABLE IF NOT
|
||||
EXISTS` / `CREATE INDEX IF NOT EXISTS` statements, plus `ALTER TABLE` additions
|
||||
handled via the `COLUMN_MIGRATIONS` list. When you need a new table, column,
|
||||
or index on systems.db, add it there. It'll run on the next `make regen-db`
|
||||
and the meta stamp will flip because `import_economics.py` changed.
|
||||
|
||||
**Also legitimate:** edits to `server/data/systems-schema.sql` (the canonical
|
||||
DDL used by fresh builds) paired with matching entries in `MIGRATION_SQL` for
|
||||
existing DBs. The stamp's `schema_version` field records the schema file's
|
||||
SHA at generation time — change the schema, commit both files together, and
|
||||
the stamp picks it up automatically.
|
||||
|
||||
**NOT legitimate and forbidden:**
|
||||
|
||||
- Running `tooling/db/sqlite-exec` (or any raw SQL) against `systems.db` by
|
||||
hand. Any changes you make are silently reverted by the next `regen-db` run
|
||||
— your edits die, not the pipeline's.
|
||||
- One-off patch scripts that open `systems.db` and modify rows.
|
||||
- Editing the DB file with a SQLite GUI.
|
||||
- Committing `systems.db` alone, without the corresponding source change that
|
||||
would explain the diff on regen.
|
||||
|
||||
If you think you need an exception, the right move is to make the source
|
||||
change explicit instead: either edit the wiki TOMLs / JSONs that feed the
|
||||
generators, or edit `MIGRATION_SQL` / `systems-schema.sql` directly. There is
|
||||
no hand-edit path that survives regen.
|
||||
|
||||
---
|
||||
|
||||
## Savegame migration lineage (Phase 5+)
|
||||
|
||||
`meta.schema_version` now stores a monotonic semver string (#888). When the savegame
|
||||
system is built (Phase 5+), a save file records its `schema_version` string; the
|
||||
loader can determine which migrations to apply by comparing that version to the
|
||||
current one. `meta.schema_sha` retains the old SHA-1 for tamper detection.
|
||||
|
||||
**When to bump `SCHEMA_VERSION`:** edit the `SCHEMA_VERSION = "1.0.0"` constant in
|
||||
`tooling/schema_version.py` whenever a schema change is backwards-incompatible
|
||||
(column removed, type changed, FK constraint added, table dropped). Additive changes
|
||||
(new nullable columns, new tables, new indexes) do not require a bump.
|
||||
@@ -58,6 +58,9 @@
|
||||
"Bash(ruff check)",
|
||||
"Bash(tests/run-*)",
|
||||
|
||||
"Bash(mkdir -p docs/sprints/*)",
|
||||
"Write(docs/sprints/*)",
|
||||
|
||||
"Bash(chmod *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(find *)",
|
||||
|
||||
@@ -26,6 +26,20 @@ current branch — never touches main.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 0. Dry-run mode check
|
||||
|
||||
If the user invokes `/pr-push --dry-run`:
|
||||
- Print: "Dry-run mode — inspecting state, nothing will be pushed or committed."
|
||||
- Run steps 1 through 4a in **inspect-only** mode:
|
||||
- Step 4: run `make check-systems-db` to check current stamp freshness (no merge)
|
||||
- Step 4a: report which watched files changed vs origin/main; show whether `make regen-db`
|
||||
would be triggered; do NOT run the regen, stage, or commit
|
||||
- Print a summary: watched files changed (list), regen needed (yes/no), DB stamp fresh (yes/no)
|
||||
- Print "Dry run complete — use /pr-push to apply."
|
||||
- Stop. Do not push or create a PR.
|
||||
|
||||
---
|
||||
|
||||
### 1. Validate branch
|
||||
|
||||
```bash
|
||||
@@ -201,6 +215,78 @@ git merge origin/main --no-edit
|
||||
If merge conflicts, **stop and report** — let the user resolve.
|
||||
If clean, continue.
|
||||
|
||||
### 4a. Regen systems.db if generator sources or data changed (#858)
|
||||
|
||||
Check whether any file in the **source-file watch list** was modified on this branch
|
||||
versus `origin/main`. This list covers generator code AND the data files that feed them.
|
||||
|
||||
The generator-source paths below **must stay in sync** with `GENERATOR_SOURCES` in
|
||||
`tooling/check-systems-db-stamp` (PR #136 review T7) — if you add a new source file
|
||||
to the stamp, add it here too, and vice versa. Drift between the two lists reintroduces
|
||||
exactly the silent-stale-DB class of bug this skill exists to prevent.
|
||||
|
||||
```bash
|
||||
git diff --name-only origin/main...HEAD -- \
|
||||
tooling/economy-db/import_economics.py \
|
||||
tooling/planet-gen/generate_atlas.py \
|
||||
tooling/planet-gen/gemma_naming.py \
|
||||
tooling/planet-gen/naming_core.py \
|
||||
tooling/planet-gen/import_city_names.py \
|
||||
tooling/planet-gen/import_heightmaps.py \
|
||||
tooling/planet-gen/import_province_boundaries.py \
|
||||
server/src/bin/generate_brands/main.rs \
|
||||
server/src/bin/generate_brands/names.rs \
|
||||
tooling/generate-brands \
|
||||
server/data/systems-schema.sql \
|
||||
wiki/star-systems/ \
|
||||
wiki/economics/ \
|
||||
content/economics/
|
||||
```
|
||||
|
||||
**If output is empty:** skip this step entirely.
|
||||
|
||||
**If any files appear in the output:** the DB must be regenerated on top of the
|
||||
current main. Perform the following:
|
||||
|
||||
1. **Integrate main.** Step 4 merged main into the branch. If you find yourself
|
||||
on a branch that was NOT yet merged with main in step 4, do it now:
|
||||
```bash
|
||||
git fetch origin
|
||||
git merge origin/main --no-edit
|
||||
```
|
||||
If there are merge conflicts in source files, **stop and report which files
|
||||
conflict**. Ask the user to resolve manually — do not attempt to auto-resolve
|
||||
generator source conflicts.
|
||||
|
||||
2. **Regenerate the DB:**
|
||||
```bash
|
||||
make regen-db
|
||||
```
|
||||
`make regen-db` runs all three generators and stamps the meta table. It tolerates
|
||||
coverage gate failures (exit 2 = data quality warning, not an error). If it exits
|
||||
with any other non-zero code, stop and report the stderr output — do not push.
|
||||
|
||||
3. **Stage the updated DB:**
|
||||
```bash
|
||||
git add server/data/systems.db
|
||||
```
|
||||
|
||||
4. **Commit only if the DB actually changed:**
|
||||
```bash
|
||||
git diff --cached --stat -- server/data/systems.db
|
||||
```
|
||||
- If the diff shows changes: commit with `/git-commit`, message:
|
||||
`chore(db): regen systems.db against rebased sources`
|
||||
- If no diff (regen produced identical output — sources were self-consistent):
|
||||
unstage the file (`git restore --staged server/data/systems.db`) and skip the
|
||||
commit. The source changes alone are the PR content.
|
||||
|
||||
**In dry-run mode** (from step 0): report which watch-list files changed and
|
||||
whether regen would be triggered. Do NOT run the regen or modify any files.
|
||||
|
||||
This step prevents the pre-push hook from rejecting a push where the branch modifies
|
||||
a generator source or data file but did not regenerate the DB.
|
||||
|
||||
### 5. Push
|
||||
|
||||
```bash
|
||||
|
||||
@@ -138,24 +138,57 @@ Three-dot diff with pathspec exclusions is unreliable. Instead, either:
|
||||
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||
raw diff to reviewers — cleaner context, better reviews.
|
||||
|
||||
**Reviewer agents read source files via `git show`.** Sprint branches
|
||||
use the naming pattern `sprint-{N}/{team}`. To read a file from the
|
||||
branch being reviewed:
|
||||
**Reviewer agents read source files from the team worktree.**
|
||||
|
||||
Sprint branches follow `sprint-{N}/{team}`. Mid-sprint, a worktree
|
||||
of each branch exists at `$(dirname <repo_root>)/.sprint/sprint-{N}/{team}/`
|
||||
— a *sibling* of the repo root, not a child. This worktree IS the
|
||||
branch: Read/Grep on paths rooted there resolve against the branch's
|
||||
checkout, not main's.
|
||||
|
||||
**Why this matters:** Sprint 37 PR #138 review produced 6 false-
|
||||
positive findings because the reviewer defaulted to Read/Grep on the
|
||||
main repo path (`/var/mnt/data/projects/settled-reach/main/`) instead
|
||||
of the branch worktree. Every finding was a verbatim match against
|
||||
main's state but irrelevant to the branch — the branch had already
|
||||
cleaned the residue the reviewer flagged as "still present." Sending
|
||||
those findings to the team would have caused busywork on already-clean
|
||||
code, and more dangerously, the same drift hides *false negatives*
|
||||
(branch-introduced bugs the reviewer never saw because it never read
|
||||
the branch).
|
||||
|
||||
Fix: before spawning reviewers, resolve the worktree path and pass it
|
||||
into every reviewer prompt with prominent language. The reviewer
|
||||
reads from the worktree, not from main.
|
||||
|
||||
```bash
|
||||
git show origin/<branch>:<path>
|
||||
# Determine worktree path
|
||||
SPRINT_NUM=$(echo "<branch>" | sed -E 's|sprint-([0-9]+)/.*|\1|')
|
||||
TEAM=$(echo "<branch>" | sed -E 's|sprint-[0-9]+/||')
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
WORKTREE="$(dirname "$REPO_ROOT")/.sprint/sprint-${SPRINT_NUM}/${TEAM}"
|
||||
|
||||
# Verify it exists and matches the branch tip
|
||||
git -C "$WORKTREE" rev-parse HEAD # should equal `git rev-parse origin/<branch>`
|
||||
```
|
||||
|
||||
For example:
|
||||
```bash
|
||||
git show origin/sprint-31/server:server/src/bin/atlas.rs
|
||||
```
|
||||
If the worktree exists and its HEAD matches `origin/<branch>`, use it
|
||||
as the reviewer's source of truth. If it doesn't exist (e.g. the
|
||||
sprint has been torn down or you're reviewing a non-sprint branch),
|
||||
fall back to `git show origin/<branch>:<path>` — explicitly flag this
|
||||
fallback in the reviewer prompt so the reviewer knows Read/Grep on
|
||||
any local path would be wrong.
|
||||
|
||||
If the sprint branch has an active worktree (under `.sprint/`), reviewers
|
||||
can also use the Read tool with the worktree path. But `git show` is
|
||||
the reliable default — it works whether or not a worktree exists.
|
||||
In the reviewer prompt, state the rule non-negotiably:
|
||||
|
||||
Also tell agents to read relevant `decisions/*.md` files for context.
|
||||
> **Read source from `<WORKTREE_PATH>` only.** Do NOT Read or Grep
|
||||
> paths under the main repo root (`/var/mnt/data/projects/settled-reach/main/`).
|
||||
> Those resolve to main, not the branch. The worktree at `<WORKTREE_PATH>`
|
||||
> IS the branch — point all file tools there.
|
||||
|
||||
Also tell agents to read relevant `decisions/*.md` files for context
|
||||
(these can be read from either path — they're usually identical —
|
||||
but for consistency, use the worktree path).
|
||||
|
||||
### 4. Spawn reviewers in parallel
|
||||
|
||||
@@ -235,16 +268,27 @@ After presenting results to the user, post the review as a PR comment.
|
||||
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
||||
|
||||
Post using the `tea-comment` wrapper (handles temp files and cleanup).
|
||||
Write the review to a temp file first, then pass via `@filepath` syntax:
|
||||
|
||||
```bash
|
||||
# Write review to file, then post — avoids $() in the command which breaks permissions
|
||||
cat > /tmp/pr-review-<NUMBER>.md << 'EOF'
|
||||
...review content...
|
||||
EOF
|
||||
**Two rules:**
|
||||
1. **Use the Write tool** for the file content (no permission prompt, no
|
||||
heredoc parsing issues with markdown tables/pipes). Then call
|
||||
`tooling/tea-comment` in a separate short Bash call.
|
||||
2. **Run `tooling/tea-comment` in the FOREGROUND, never with
|
||||
`run_in_background`.** The background execution path silently fails —
|
||||
the comment never reaches Gitea and the team never sees the review.
|
||||
Sprint 38 lost an entire review round this way. Always foreground.
|
||||
|
||||
```
|
||||
# Step 1: Use the Write tool to create the file
|
||||
Write({ file_path: "/tmp/pr-review-<NUMBER>.md", content: "..." })
|
||||
|
||||
# Step 2: Post via short Bash call (foreground)
|
||||
tooling/tea-comment <PR_NUMBER> @/tmp/pr-review-<NUMBER>.md
|
||||
```
|
||||
|
||||
Do NOT use `cat << 'EOF'` heredocs for review content — they create
|
||||
massive permission prompts that are slow to render and often get stuck.
|
||||
|
||||
## 7. Merging approved PRs
|
||||
|
||||
`tea pr merge` fails (405) when branches have conflicts with main. Merge
|
||||
|
||||
@@ -130,17 +130,34 @@ If the user raises items that should be tracked, create Q-NNN entries
|
||||
or backlog tickets on the spot. If process changes are agreed, update
|
||||
the relevant skill files or CLAUDE.md immediately — don't defer them.
|
||||
|
||||
#### A1c. Clean up sprint worktrees
|
||||
#### A1c. Clean up sprint worktrees (MANDATORY — do not skip)
|
||||
|
||||
Remove ephemeral worktrees for the closed sprint. Run the teardown script:
|
||||
Always run the teardown script. It's idempotent and prints
|
||||
"No worktrees found" gracefully if there's nothing to clean:
|
||||
|
||||
```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).
|
||||
**Do not try to pre-check whether worktrees exist by running `ls`
|
||||
locally.** Sprint worktrees live at
|
||||
`$(dirname <repo-root>)/.sprint/sprint-{N}/` — a *sibling* of the
|
||||
repo root, not a child. Running `ls .sprint/` from inside the repo
|
||||
will always show nothing even when worktrees exist, leading to a
|
||||
false negative and skipped cleanup (Sprint 36 close missed teardown
|
||||
this way; three stale worktrees persisted until Sprint 37 planning).
|
||||
|
||||
The script knows the correct path via its own `SCRIPT_DIR` — trust it.
|
||||
|
||||
Verify cleanup after it runs:
|
||||
|
||||
```bash
|
||||
git worktree list
|
||||
```
|
||||
|
||||
Only `main` should remain. Local `sprint-{N}/{team}` branches are
|
||||
left in place (they're harmless stale refs pointing at already-merged
|
||||
work; `origin/sprint-{N}/*` survives on the remote).
|
||||
|
||||
#### A2. Bump the version
|
||||
|
||||
@@ -348,11 +365,25 @@ using `TaskUpdate` with `addBlockedBy`.
|
||||
For each agent from the `**Agents:**` line, spawn a teammate in the
|
||||
background. Spawn all agents in parallel (one message, multiple Task calls):
|
||||
|
||||
**Model pin (MANDATORY for team members):** every team-mode spawn —
|
||||
i.e. any `Task` with a `team_name` argument — must pass `model: "sonnet"`.
|
||||
Sprint 37 observed Opus 4.7 teammates ignoring scope rules, leaving
|
||||
tasks half-done, and failing to report back via SendMessage. Sonnet 4.6
|
||||
follows literal rules block discipline better. The **team lead**
|
||||
(this session, running `/sprint-start`) stays on whatever model the
|
||||
user has selected — typically Opus.
|
||||
|
||||
**Inline (non-team) Agent spawns are exempt.** One-shot reviewers
|
||||
(`/pr-review`), research subagents, and other `Task` calls without a
|
||||
`team_name` keep their default model. The pin applies to the
|
||||
long-running team-coordination path specifically, not every Agent call.
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "{name_lowercase}",
|
||||
team_name: "sprint-{N}-{team}",
|
||||
name: "{name_lowercase}",
|
||||
model: "sonnet",
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `sprint-{N}/{team}`
|
||||
|
||||
@@ -406,6 +437,9 @@ Task(
|
||||
|
||||
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
||||
2. Read the decision files referenced in the briefing.
|
||||
If your work touches systems.db sources (markers.json, TOML files,
|
||||
or generator code), read .claude/rules/asset-pipeline.md before
|
||||
modifying anything.
|
||||
3. Check TaskList for available work.
|
||||
4. Claim an unblocked task (TaskUpdate with owner: your name),
|
||||
mark it in_progress, and implement it.
|
||||
|
||||
@@ -61,6 +61,7 @@ Use the Task tool to spawn each agent as a teammate. Each call should:
|
||||
- Set `team_name` to the workshop team name
|
||||
- Set `name` to the agent name (e.g., "gestalt")
|
||||
- Set `subagent_type` to the matching agent type (same as name — see reference table)
|
||||
- **Set `model: "sonnet"`** — team-mode participants pin to Sonnet 4.6 for literal-rule-following discipline. Opus 4.7 was observed ignoring scope rules and failing to report back in team mode (Sprint 37). The *team lead* (this session) stays on whatever model the user has selected.
|
||||
- Provide a prompt telling the agent to check TaskList for their assigned task
|
||||
|
||||
Spawn all agents in parallel (one Task call per agent in a single message). Agents will appear as teammates in the Claude Code UI and pick up their tasks from the shared task list.
|
||||
|
||||
+56
-4
@@ -9,14 +9,27 @@ ERRORS=0
|
||||
echo "pre-push: running lint checks..."
|
||||
|
||||
# --- Detect which directories have changes vs remote ---
|
||||
# Prefer origin/<branch> as the baseline (what the remote already has),
|
||||
# but fall back to origin/main for first-push of a new branch — otherwise
|
||||
# every check runs against nothing and the hook treats the whole repo as
|
||||
# changed, spending tens of seconds on linters and JSON validation that
|
||||
# have no diff to cover (e.g. pushing a wiki-only branch rebuilds GDScript
|
||||
# and runs clippy + ruff + validates all 2762 JSON files).
|
||||
BRANCH=$(git branch --show-current)
|
||||
REMOTE_REF="origin/$BRANCH"
|
||||
if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; then
|
||||
if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
|
||||
REMOTE_REF="origin/$BRANCH"
|
||||
elif git rev-parse --verify "origin/main" >/dev/null 2>&1; then
|
||||
REMOTE_REF="origin/main"
|
||||
else
|
||||
REMOTE_REF=""
|
||||
fi
|
||||
|
||||
if [ -n "$REMOTE_REF" ]; then
|
||||
CLIENT_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- client/ 2>/dev/null | wc -l)
|
||||
SERVER_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- server/ 2>/dev/null | wc -l)
|
||||
TOOLING_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- tooling/ pyproject.toml 2>/dev/null | wc -l)
|
||||
else
|
||||
# New branch or no remote ref — fall through to directory checks
|
||||
# No remote at all (e.g. fresh clone before first fetch) — be safe, run everything
|
||||
CLIENT_CHANGED=1
|
||||
SERVER_CHANGED=1
|
||||
TOOLING_CHANGED=1
|
||||
@@ -114,7 +127,12 @@ else
|
||||
fi
|
||||
|
||||
# --- JSON syntax validation ---
|
||||
if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; then
|
||||
# Use the same REMOTE_REF the directory-change detection above settled on
|
||||
# (origin/<branch> preferred, origin/main fallback). Without this, a first
|
||||
# push of a new branch falls through to "validate every JSON in the repo,"
|
||||
# which on this repo means 2762 Python parses — tens of seconds of churn
|
||||
# for a push that might not have touched any JSON at all.
|
||||
if [ -n "$REMOTE_REF" ]; then
|
||||
JSON_FILES=$(git diff --name-only "$REMOTE_REF"..HEAD -- '*.json' 2>/dev/null || true)
|
||||
else
|
||||
JSON_FILES=$(git ls-files '*.json')
|
||||
@@ -138,6 +156,40 @@ else
|
||||
echo "pre-push: no JSON changes — skipping"
|
||||
fi
|
||||
|
||||
# --- systems.db stamp check (#857) ---
|
||||
# If the branch touches server/data/systems.db and the meta stamp does not
|
||||
# match current generator sources, reject the push. Prevents pushing a
|
||||
# stale DB snapshot where generator source was modified but the DB was not
|
||||
# regenerated.
|
||||
#
|
||||
# Runs whenever systems.db was modified in ANY branch commit vs. main —
|
||||
# including on a branch's very first push (review T5: the previous version
|
||||
# skipped the check for new branches because it compared against origin/$BRANCH,
|
||||
# which didn't exist yet, leaving a gap where a stale DB could ship via the
|
||||
# first push). We compare against origin/main — which always exists — so the
|
||||
# check covers the first-push case.
|
||||
DB_IN_PUSH=$(git diff --name-only origin/main...HEAD -- server/data/systems.db 2>/dev/null | wc -l)
|
||||
if [ "$DB_IN_PUSH" -gt 0 ] && [ -f "$REPO_ROOT/tooling/check-systems-db-stamp" ]; then
|
||||
echo "pre-push: checking systems.db stamp..."
|
||||
rc=0
|
||||
python3 "$REPO_ROOT/tooling/check-systems-db-stamp" || rc=$?
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
# rc=1 means stale / unknown generator / missing source; message on stderr
|
||||
echo " Fix: run 'make regen-db' then stage server/data/systems.db"
|
||||
echo " Or use /pr-push — it handles regen automatically before pushing."
|
||||
ERRORS=$((ERRORS + 1))
|
||||
elif [ "$rc" -eq 2 ]; then
|
||||
# rc=2 means no meta table — treat as unstamped, warn but don't block.
|
||||
# This is legitimate immediately after the meta table is introduced;
|
||||
# the next `make regen-db` will populate it (H4).
|
||||
echo "pre-push: WARNING — systems.db has no meta stamp — run 'make regen-db' now if this DB was generated by you"
|
||||
else
|
||||
echo "pre-push: systems.db stamp — OK"
|
||||
fi
|
||||
else
|
||||
echo "pre-push: systems.db not in push — skipping stamp check"
|
||||
fi
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "pre-push: $ERRORS check(s) failed. Push aborted."
|
||||
|
||||
@@ -6,6 +6,71 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.2.0] — 2026-05-03
|
||||
|
||||
*Process milestone: final sprint-based release. Development moves to kanban + milestones (Q-096).*
|
||||
|
||||
## [v0.1.38] — 2026-05-03
|
||||
|
||||
### Added
|
||||
- **Generation cascade D-records** (D-194–D-218) — 25 decisions formalizing the full pipeline from planetary heightmap to walkable tile: WorldTier taxonomy, settlement classification, city generation context, drainage routing, attractor matching, district mix, block irregularity, tile conditions
|
||||
- **Atlas data pipeline** (#901–#911) — new `atlas_body_heightmaps`, `atlas_city_names`, `atlas_feature_names`, `atlas_province_boundaries` tables; `body_radius_km` column; three new importers (heightmaps, city names, province boundaries via D8 watershed); `economic_role` normalized to 7 canonical values
|
||||
- **Phase 1 generation pipeline** (#916–#924) — 10-module `server/src/atlas/` package: heightmap BLOB loader, BodyWorldState LRU cache, D8 drainage routing, background generation queue with Rayon pool, five-phase attractor matching, three-component district mix, block irregularity, tile condition thresholds
|
||||
- **District skeleton generator** (#899) — `generate_skeleton()` wires the full atlas pipeline to produce filled `DistrictSkeleton` instances from city markers + planet data. Phase 1 scope: SettingType/ComplexityTier derivation, layout mode assignment, 4×4 block grid with zoning, multi-block reservations
|
||||
- **SystemNameIndex** (#926) — Aho-Corasick text scanner over body/station/system names for background pre-generation queue integration (D-206)
|
||||
- **Free camera viewer** (#898) — F4 toggles decoupled camera with WASD pan + scroll zoom; input suppressed in free-camera mode; implant UI remains accessible
|
||||
- **Fog behavioral tests** (#879) — 11 new tests covering EXP_EXPLORED persistence, grow-only bounds, texture-resize copy, BoundaryWall handling
|
||||
- **Province boundary rendering** (#927) — drainage basin polylines exported to markers.json and rendered on the planetary map under the political_zones overlay
|
||||
- **Stamp expansion** (#892) — `gemma_naming.py` and `naming_core.py` added to `check-systems-db-stamp` source tracking and `/pr-push` watch list
|
||||
- **`make decisions-orphan-tickets`** (#887) — new CLI subcommand (`tooling/db/decision orphan-tickets`) that scans tickets with a `decision_ref` not matching any decision in the DB, surfacing silently orphaned tickets from typo'd or renumbered D-IDs
|
||||
|
||||
### Changed
|
||||
- **`meta.schema_version` switched to monotonic semver** (#888) — replaces SHA-1 hash with an orderable semver string (`"1.0.0"`); old SHA preserved in new `schema_sha` column for tamper detection; `check-systems-db-stamp` now rejects legacy SHA-hex values
|
||||
- **Archetype strip** (#882) — removed `character_archetype`, `lattice_profile`, lattice color palettes, and all related test assertions from client
|
||||
- **Corporation wiki review** (#884) — 19 corporation pages corrected: 6 hop-count fixes, topology label corrections, Rush Mining and Scapa Flow narratives rewritten for star-map accuracy, tag reordering, stub-to-prose rewrites
|
||||
|
||||
### Fixed
|
||||
- **Bevy baseline test panics** (#885) — `SnapshotBuffer` Option-wrapped in economy.rs, `TickPhase::configure` added to SimulationPlugin, stale golden file regenerated. All 6 previously-failing tests pass
|
||||
- **Suffix monotony auto-fix** (#886) — `gemma_naming.py` re-queries affected bodies when >40% suffix clustering detected; cultural-history context threaded into naming prompts
|
||||
- **Client parse-order violations** — sim_bridge, protocol, input_mapper, audio_manager, main_menu all fixed to follow autoload pattern (untyped fields + runtime `load()`)
|
||||
- **Confrontation monologue signal** (#867) — tween validity guard ensures signal fires in headless test mode
|
||||
- **Pre-existing test failures** (#871) — 7 tests fixed inline (examine_display dismiss timing, fog position fragility, rendering snapshot assertions, time display format)
|
||||
|
||||
## [v0.1.37] — 2026-04-22
|
||||
|
||||
### Added
|
||||
- **Asset pipeline discipline** (#854, #855, #856, #857, #858, #859) — `systems.db` is now a source-canonical snapshot with a `meta` table stamped by every generator (SHA of source + schema). Pre-push hook rejects stale DBs; `/pr-push` auto-runs `make regen-db` when generator sources change. Full rules in `.claude/rules/asset-pipeline.md`
|
||||
- **`make regen-db`** — runs the two DB-writing generators (import_economics, generate_atlas) and stamps the meta table. import_economics now invokes the Rust generate_brands binary internally as its first step, so the brand pipeline is owned by a single stamp.
|
||||
- **`make check-systems-db`** — verifies the meta stamp matches current generator sources
|
||||
- **`make install-hooks`** — installs pre-push and pre-commit hooks in one step
|
||||
- **`tooling/db/decision show <D-NNN>`** (#723) — drill-down view of a decision with implementing tickets and cross-refs
|
||||
- **Atlas determinism smoke test** (#847) — `make test-atlas-determinism` runs `generate_atlas.process_body()` twice with a fixed seed and diffs the output to catch determinism regressions in terrain analysis, city placement, A* routing, and naming
|
||||
- **SelectedBookmark save/load** (#863) — bookmark and starting-location choice now persist across save/load; replaces the v0.2-deferred TODO on `SelectedBookmark`
|
||||
- **`BookmarkPlugin::new(registry)` injection** (#862) — test-friendly plugin construction for future TOML bookmark loading; default constructor still wires the canonical tycoon registry
|
||||
- **Six new corporation wiki pages** (#860) — Arbour Aggregates, Earth Standard Group, Rush Mining, Scapa Flow Industries, Sede Chemical Works, Threshold Fuel Syndicate
|
||||
- **Atlas naming corridor-scoped dedup, compass-direction filter, river vocab filter, infra pair-naming** (#853) — city and mountain names deduplicate across bodies within a corridor; compass-direction defaults blocked in the few-shot prompt; navigational vocabulary (`Flow`, `Current`) rejected for rivers; unnamed roads and railroads receive deterministic `{CityA}–{CityB} {corridor_suffix}` names
|
||||
- **Scene-level merge-path UI flow tests** (#873) — `test_merge_path_flows_sprint37.gd` covers main-menu → new-game, load-game, character-creation → submit, bookmark → confirm. Headless scene-flow tier (4th beyond Gauntlet/MessagePack/TestHarness); pattern for future merge-path regression guards
|
||||
- **105 brand corp wiki stubs** (#861) — every corp page from Sprint 36 PR #133 now authored to three-layer narrative depth (public identity / actual operation / one concealed fact) with ≥95-line DoD
|
||||
- **D-193 Lattice Commission** (#876) — resolves Q-095: "the Lattice Commission" is the canonical long-form of the Concord Assembly's regulatory authority; "Concord Commission" and "Assembly Commission" deprecated as drift forms
|
||||
|
||||
### Changed
|
||||
- `decisions-coverage` Makefile target now lists implementing ticket IDs per decision instead of aggregate counts
|
||||
- **Economy coverage gate** (#860) now passes end-to-end — closes the 21 raw-commodity / system gaps that blocked Phase 2 demand simulation
|
||||
- Tag updates on 15 existing corporation wiki pages to match commodity coverage needs
|
||||
|
||||
### Fixed
|
||||
- **New Game flow hangs on 'connecting'** (#872) — `bookmark_catalog` carry-forward race in `SimBridge.receive_bytes` when tick 0 + tick 1 arrived in the same TCP batch; catalog now carries forward with same invariants as monologue/dialogue/settings_response
|
||||
- **`dialogue_box._escape_bbcode` corrupted `[lb]` escapes** (#866) — chained `.replace('[','[lb]').replace(']','[rb]')` turned `[lb]` into `[lb[rb]`; fix escapes only `[`, since unmatched `]` renders as literal in RichTextLabel
|
||||
- **MetaScreen test helper regression in `test_anti_tedium`** (#869) — bug_report_dialog test helpers now instantiate from `.tscn` instead of `Control.new() + set_script()`, preserving the MetaScreen runtime stack
|
||||
- Storyteller `activation_pass` "no Simmering triangles — holding" no longer fires as `warn` during normal early-game state — downgraded to `debug` (#789)
|
||||
|
||||
### Removed
|
||||
- **`PROTOCOL_VERSION` lockstep handshake** (#874, #875, D-192) — both sides of the handshake now omit the version field; `HandshakeMessage` is empty server-side and the client decode path no longer checks versions. Schema drift surfaces as MessagePack missing-field errors downstream, which is the intended signal
|
||||
- **`HeritageRoot` type alias and `ZonePaletteModifier::Heritage` variant** (#877, D-167) — last stubs of the abstract heritage-root system retired in favour of the corridor cultural framework
|
||||
- **`CharacterArchetype` (Smuggler/Detective) trace from server** (#878) — enum, IPC field, verb-differentiation branch in the observer Phase 2 filter (D-057 superseded), monologue pool partitioning, Gauntlet plumbing, drama-module schema, archetype-dependent integration tests. Per the development cascade, character/NPC differentiation is Phase 6 work and the running trace was pre-cascade filler, not production. Client-side cleanup tracked in #882.
|
||||
- **v0.1 Sova/Van Maanen's residue from wiki** (#865) — `wiki/star-systems/GJ-35/sova/` subtree deleted; authoring-guide examples stripped; canonical lore citing dropped v0.1 NPCs rewritten; "Van Maanen's Star" cultural references converted to "Vuurkloof"
|
||||
- **8 parse-error test files** (#870) — `test_debug_overlay_sprint19`, `test_entanglement_sprint22`, `test_fog_sprint22`, `test_journal_sprint18`, `test_minimap_sprint18`, `test_session_manager_sprint19`, `test_sprint30`, `test_sprite_integration` — referenced removed/renamed APIs from prior sprints. Coverage-revival tickets filed: #879 (fog), #880 (journal), #881 (minimap), #889 (EntityRenderer sprite constants); rest tracked under umbrella #871
|
||||
|
||||
## [v0.1.36] — 2026-04-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -25,6 +25,12 @@ Full annotated tree: `.claude/rules/project-structure.md`
|
||||
|
||||
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
|
||||
|
||||
### Asset pipeline
|
||||
|
||||
`server/data/systems.db` is a read-only canonical snapshot produced by the generator
|
||||
pipeline — never edit it directly. To regenerate: `make regen-db`. Full rules in
|
||||
`.claude/rules/asset-pipeline.md`.
|
||||
|
||||
## Development Cascade — First Things First
|
||||
|
||||
Development follows a strict cascade. Each phase has a concrete deliverable. **Do NOT discuss, design, or implement detail from a later phase while an earlier phase is incomplete.** If you encounter references to later-phase detail (room grammar, NPC bundles, heritage tokens, etc.) in documents or decisions, either ignore them silently and stay at the correct level, or flag that the reference is dragging attention to the wrong scope level and suggest it be rephrased or moved.
|
||||
@@ -89,7 +95,7 @@ The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var
|
||||
| Sprints | `tooling/db/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||
| SQL queries | `tooling/db/sqlite-query "SELECT ..."` | — |
|
||||
| SQL writes | `tooling/db/sqlite-exec "UPDATE ..."` | — |
|
||||
| Decisions | `tooling/db/decision next`, `claim`, `check-dupes` | — |
|
||||
| Decisions | `tooling/db/decision show`, `next`, `claim`, `check-dupes` | — |
|
||||
|
||||
### Testing preferences
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 lint-python setup-venv ci ci-client ci-server clean \
|
||||
decisions-sync decisions-coverage decisions-active decisions-orphan \
|
||||
db-backup db-install validate-content check-fact-ids setup-hooks \
|
||||
audit deny atlas-verify economy-db atlas-generate \
|
||||
decisions-sync decisions-coverage decisions-active decisions-orphan decisions-orphan-tickets \
|
||||
db-backup db-install validate-content check-fact-ids setup-hooks install-hooks \
|
||||
audit deny atlas-verify economy-db atlas-generate regen-db check-systems-db \
|
||||
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
|
||||
pre-pr-server pre-pr-client pre-pr-content \
|
||||
fixtures-client fixtures-gauntlet golden-diff golden-update \
|
||||
@@ -46,9 +46,10 @@ help:
|
||||
@echo " make db-install Restore shared database from backup"
|
||||
@echo ""
|
||||
@echo " make decisions-sync Sync decisions/*.md into SQLite"
|
||||
@echo " make decisions-coverage Decision-to-ticket coverage by domain"
|
||||
@echo " make decisions-coverage Each decision with its implementing ticket(s)"
|
||||
@echo " make decisions-active List active decisions"
|
||||
@echo " make decisions-orphan Decisions without implementing tickets"
|
||||
@echo " make decisions-orphan Decisions without implementing tickets"
|
||||
@echo " make decisions-orphan-tickets Tickets with invalid or missing decision_ref"
|
||||
@echo " make audit Run cargo audit (security advisory check)"
|
||||
@echo " make deny Run cargo deny check (license/ban policy)"
|
||||
@echo " make validate-content Validate content YAML against schemas"
|
||||
@@ -58,6 +59,10 @@ help:
|
||||
@echo " make check-star-map Assert star_map_data.json is up to date (part of pre-pr-client)"
|
||||
@echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)"
|
||||
@echo " make atlas-generate Generate atlas city/road/rail markers for all inhabited bodies"
|
||||
@echo " make regen-db Regenerate systems.db from all sources + stamp meta table (#855)"
|
||||
@echo " make check-systems-db Verify systems.db meta stamp matches current generator sources"
|
||||
@echo " make install-hooks Install pre-push + pre-commit git hooks (once per clone)"
|
||||
@echo " make test-atlas-determinism Determinism smoke test for generate_atlas.py (#847)"
|
||||
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
|
||||
@echo " make golden-diff Show diff if golden file output has changed"
|
||||
@echo " make golden-update Regenerate golden file and stage for commit"
|
||||
@@ -110,6 +115,10 @@ setup-hooks:
|
||||
@git config core.hooksPath .config/hooks
|
||||
@echo "Git hooks path set to .config/hooks"
|
||||
|
||||
install-hooks: setup-hooks
|
||||
@chmod +x .config/hooks/pre-push .config/hooks/pre-commit
|
||||
@echo "Hooks installed — pre-push and pre-commit are active."
|
||||
|
||||
setup-venv:
|
||||
@python3 -m venv .venv
|
||||
@.venv/bin/pip install -e ".[dev]" --quiet
|
||||
@@ -223,6 +232,9 @@ test-ipc-integration:
|
||||
test-ipc-benchmark:
|
||||
tests/run-ipc-benchmark
|
||||
|
||||
test-atlas-determinism: ## Determinism smoke test for generate_atlas.py (#847)
|
||||
tests/run-atlas-determinism
|
||||
|
||||
# --- Clean ---
|
||||
|
||||
clean-imports:
|
||||
@@ -347,6 +359,27 @@ atlas-generate: ## Generate atlas markers (cities, roads, rail) for all inhabit
|
||||
echo " [guard] $$count bodies with terrain_reference — proceeding."
|
||||
@python3 tooling/planet-gen/generate_atlas.py --seed 42
|
||||
|
||||
regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855, #856)
|
||||
@# Run as a single shell so `set -e` covers all steps. Without this
|
||||
@# each recipe line was a fresh shell and a failure in step 1 did not
|
||||
@# halt step 2, which could produce stale data with a fresh stamp
|
||||
@# (PR #136 review T4). import_economics' exit code 2 is a valid
|
||||
@# coverage-gate-warning state (DB and stamp committed), not an error,
|
||||
@# so it's explicitly tolerated. Any other non-zero exit halts the
|
||||
@# pipeline immediately.
|
||||
@set -e; \
|
||||
echo " [regen-db] Importing economics data (runs generate_brands internally)..."; \
|
||||
ec=0; python3 tooling/economy-db/import_economics.py || ec=$$?; \
|
||||
if [ $$ec -ne 0 ] && [ $$ec -ne 2 ]; then exit $$ec; fi; \
|
||||
echo " [regen-db] Running atlas generator..."; \
|
||||
python3 tooling/planet-gen/generate_atlas.py --seed 42; \
|
||||
echo ""; \
|
||||
echo " regen-db complete — systems.db is up to date and stamped."; \
|
||||
echo " Stage it with: git add server/data/systems.db"
|
||||
|
||||
check-systems-db: ## Verify systems.db meta stamp matches current generator sources (#857)
|
||||
@python3 tooling/check-systems-db-stamp --verbose
|
||||
|
||||
econ-sim: ## Build the economics simulation binary (Layer 1+2: Leontief + tâtonnement trade)
|
||||
@cargo build --manifest-path tooling/econ-sim/Cargo.toml --release
|
||||
@echo "Built: tooling/econ-sim/target/release/econ-sim"
|
||||
@@ -364,7 +397,7 @@ decisions-sync:
|
||||
@tooling/db/decisions-sync
|
||||
|
||||
decisions-coverage:
|
||||
@tooling/db/sqlite-query "SELECT d.domain, COUNT(DISTINCT d.id) as decisions, COUNT(DISTINCT t.decision_ref) as with_tickets FROM decisions d LEFT JOIN tickets t ON d.id = t.decision_ref WHERE d.status='active' AND d.type='confirmed' GROUP BY d.domain"
|
||||
@tooling/db/sqlite-query "SELECT d.id, d.domain, d.title, COALESCE(GROUP_CONCAT(t.id, ', '), '') as implementing_tickets FROM decisions d LEFT JOIN tickets t ON d.id = t.decision_ref WHERE d.status='active' AND d.type='confirmed' GROUP BY d.id ORDER BY d.domain, d.id"
|
||||
|
||||
decisions-active:
|
||||
@tooling/db/sqlite-query "SELECT id, domain, title FROM decisions WHERE status='active' AND type='confirmed' ORDER BY domain, id"
|
||||
@@ -372,6 +405,9 @@ decisions-active:
|
||||
decisions-orphan:
|
||||
@tooling/db/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
|
||||
|
||||
decisions-orphan-tickets:
|
||||
@tooling/db/decision orphan-tickets
|
||||
|
||||
# --- Content Validation ---
|
||||
|
||||
validate-content:
|
||||
|
||||
@@ -124,6 +124,11 @@ stance_down={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
free_camera={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
bug_report={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
|
||||
@@ -231,7 +231,8 @@ func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
|
||||
var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "")
|
||||
if asset_key.is_empty():
|
||||
return
|
||||
play_at(asset_key, world_tile_pos * Constants.TILE_SIZE)
|
||||
var C := load("res://scripts/constants.gd")
|
||||
play_at(asset_key, world_tile_pos * C.TILE_SIZE)
|
||||
|
||||
|
||||
# --- Playback: spatial (D-018 close-range) ---
|
||||
|
||||
@@ -41,11 +41,6 @@ var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs
|
||||
# v5 fields (#414)
|
||||
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
|
||||
|
||||
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
|
||||
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
|
||||
# Server sends this field as part of the player's capability snapshot.
|
||||
var lattice_profile: String = "lattice_baseline"
|
||||
|
||||
# v6 fields (#449, D-053, D-065)
|
||||
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
|
||||
var player_inventory: Array = [] # [{item_id, name, slot}]
|
||||
@@ -94,10 +89,8 @@ var debug_response: Variant = null
|
||||
# Format: user://saves/<game-id>/<filename>.sav or "" if no pending load.
|
||||
var pending_load_path: String = ""
|
||||
|
||||
# #588: Character archetype chosen at character select screen.
|
||||
# "detective" or "smuggler". Set before game scene loads; sent in StartupMessage.
|
||||
# Default: "detective" — fallback for legacy saves without character.txt.
|
||||
var character_archetype: String = "detective"
|
||||
# #898: Free camera mode — camera decoupled from player, WASD pans camera directly.
|
||||
var free_camera_mode: bool = false
|
||||
|
||||
# #705: Character visual descriptor — set by character_creation.gd on confirmation.
|
||||
# Passed to EntityRenderer for the player entity's CharacterVisual on game start.
|
||||
|
||||
@@ -68,7 +68,7 @@ func _process(_delta: float) -> void:
|
||||
# D-054: Update facing angle from mouse position every frame
|
||||
_update_facing_from_mouse()
|
||||
|
||||
if GameState.dialogue_active:
|
||||
if GameState.dialogue_active or GameState.free_camera_mode:
|
||||
return
|
||||
|
||||
# D-054: Send facing octant to server when it changes (even without movement)
|
||||
@@ -117,6 +117,8 @@ func _process(_delta: float) -> void:
|
||||
|
||||
# Discrete actions: fire once on key press (not held).
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if GameState.dialogue_active or GameState.free_camera_mode:
|
||||
return
|
||||
var action: Action = -1
|
||||
|
||||
if event.is_action_pressed("interact"):
|
||||
@@ -182,10 +184,11 @@ func _update_facing_from_mouse() -> void:
|
||||
if vp == null:
|
||||
return
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var player_world_px := GameState.player_position * Constants.TILE_SIZE
|
||||
var player_screen := canvas_xf * player_world_px
|
||||
var mouse_screen := vp.get_mouse_position()
|
||||
var delta := mouse_screen - player_screen
|
||||
var C := load("res://scripts/constants.gd")
|
||||
var player_world_px: Vector2 = GameState.player_position * C.TILE_SIZE
|
||||
var player_screen: Vector2 = canvas_xf * player_world_px
|
||||
var mouse_screen: Vector2 = vp.get_mouse_position()
|
||||
var delta: Vector2 = mouse_screen - player_screen
|
||||
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
|
||||
if delta.length_squared() > 4.0:
|
||||
facing_angle = delta.angle()
|
||||
|
||||
@@ -55,12 +55,11 @@ func new_game() -> String:
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
## Restores world_seed and character_archetype from the save directory.
|
||||
## Restores world_seed from the save directory.
|
||||
func resume_game(game_id: String) -> void:
|
||||
GameState.current_game_id = game_id
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
GameState.world_seed = _read_seed_file(save_path)
|
||||
GameState.character_archetype = _read_archetype_file(save_path)
|
||||
|
||||
|
||||
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||
@@ -171,29 +170,6 @@ func _read_seed_file(save_path: String) -> int:
|
||||
return file.get_64() & 0x7FFFFFFFFFFFFFFF
|
||||
|
||||
|
||||
## Write character_archetype to save directory. Called after new_game() creates the dir.
|
||||
func save_character_archetype(game_id: String, archetype: String) -> void:
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error(
|
||||
(
|
||||
"SessionManager: failed to write character.txt: %s"
|
||||
% error_string(FileAccess.get_open_error())
|
||||
)
|
||||
)
|
||||
return
|
||||
file.store_string(archetype)
|
||||
|
||||
|
||||
## Read character_archetype from save directory. Returns "detective" if missing (legacy saves).
|
||||
func _read_archetype_file(save_path: String) -> String:
|
||||
var file := FileAccess.open(save_path + "character.txt", FileAccess.READ)
|
||||
if file == null:
|
||||
return "detective"
|
||||
return file.get_as_text().strip_edges()
|
||||
|
||||
|
||||
func _find_newest_save(dir_path: String) -> String:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
|
||||
@@ -3,7 +3,7 @@ extends Node
|
||||
# Signals
|
||||
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
||||
signal snapshot_received(snapshot: Dictionary)
|
||||
signal handshake_complete(protocol_version: int)
|
||||
signal handshake_complete
|
||||
signal handshake_failed(reason: String)
|
||||
|
||||
# Connection states
|
||||
@@ -27,8 +27,8 @@ var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by
|
||||
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 _bridge = null # LocalBridge
|
||||
var _server = null # ServerProcess
|
||||
var _connect_retries: int = 0
|
||||
var _retry_timer: float = 0.0
|
||||
var _handshake_start_usec: int = 0
|
||||
@@ -126,14 +126,15 @@ func connect_to_sim() -> void:
|
||||
|
||||
# Spawn server subprocess
|
||||
if not server_path.is_empty():
|
||||
_server = ServerProcess.new()
|
||||
var SP := load("res://scripts/protocol/server_process.gd")
|
||||
_server = SP.new()
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
|
||||
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
|
||||
var args := ["127.0.0.1:" + str(server_port)]
|
||||
var game_id: String = GameState.current_game_id
|
||||
if not game_id.is_empty():
|
||||
args.append_array(["--game-id", game_id])
|
||||
var pid := _server.start(server_path, args)
|
||||
var pid: int = _server.start(server_path, args)
|
||||
if pid <= 0:
|
||||
push_error("SimBridge: failed to start server")
|
||||
_set_state(ConnectionState.ERROR)
|
||||
@@ -159,8 +160,9 @@ func disconnect_from_sim() -> void:
|
||||
|
||||
# Attempt TCP connection. Called from _process() during CONNECTING state.
|
||||
func _try_connect() -> void:
|
||||
_bridge = LocalBridge.new()
|
||||
var err := _bridge.connect_to_server("127.0.0.1", server_port)
|
||||
var LB := load("res://scripts/protocol/local_bridge.gd")
|
||||
_bridge = LB.new()
|
||||
var err: int = _bridge.connect_to_server("127.0.0.1", server_port)
|
||||
if err != OK:
|
||||
push_warning(
|
||||
(
|
||||
@@ -220,7 +222,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
_bridge.poll()
|
||||
|
||||
# Check connection dropped during handshake
|
||||
var bridge_status := _bridge.get_status()
|
||||
var bridge_status: int = _bridge.get_status()
|
||||
if (
|
||||
bridge_status == StreamPeerTCP.STATUS_ERROR
|
||||
or bridge_status == StreamPeerTCP.STATUS_NONE
|
||||
@@ -242,17 +244,15 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
return
|
||||
|
||||
# Try to read first message
|
||||
var msg := _bridge.poll_message()
|
||||
var msg: PackedByteArray = _bridge.poll_message()
|
||||
if msg.is_empty():
|
||||
return # Not ready yet, continue polling
|
||||
|
||||
# Decode HandshakeMessage: { "protocol_version": N }
|
||||
var decoded: Variant = Messagepack.decode(msg)
|
||||
if (
|
||||
decoded.status != null
|
||||
or not (decoded.value is Dictionary)
|
||||
or not decoded.value.has("protocol_version")
|
||||
):
|
||||
# Decode HandshakeMessage — D-192 (#875): protocol_version field dropped.
|
||||
# Server sends {} or a minimal dict; only structural validity is required.
|
||||
var MP = load("res://addons/messagepack/messagepack.gd")
|
||||
var decoded: Variant = MP.decode(msg)
|
||||
if decoded.status != null or not (decoded.value is Dictionary):
|
||||
var reason := "Handshake decode failed: malformed HandshakeMessage"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
@@ -260,27 +260,14 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
var server_version: int = decoded.value["protocol_version"]
|
||||
if server_version != Protocol.PROTOCOL_VERSION:
|
||||
var reason := (
|
||||
"Protocol version mismatch: server=%d, client=%d"
|
||||
% [server_version, Protocol.PROTOCOL_VERSION]
|
||||
)
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Send startup message with world_seed and character appearance (#175, D-010/D-029, #718).
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
var startup_bytes := Protocol.encode_startup_message(
|
||||
GameState.world_seed,
|
||||
GameState.character_archetype,
|
||||
GameState.character_visual_descriptor
|
||||
)
|
||||
if startup_bytes.size() > 0:
|
||||
var send_err := _bridge.send_message(startup_bytes)
|
||||
var send_err: int = _bridge.send_message(startup_bytes)
|
||||
if send_err != OK:
|
||||
var reason := "Failed to send startup message: %s" % error_string(send_err)
|
||||
push_error("SimBridge: %s" % reason)
|
||||
@@ -296,7 +283,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
handshake_complete.emit(server_version)
|
||||
handshake_complete.emit()
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
# #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled
|
||||
# from server SQLite so the client reflects the authoritative persisted state (D-138).
|
||||
@@ -319,7 +306,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
match _bridge.get_status():
|
||||
StreamPeerTCP.STATUS_CONNECTED:
|
||||
# Receive: drain all complete messages from the bridge
|
||||
var msg := _bridge.poll_message()
|
||||
var msg: PackedByteArray = _bridge.poll_message()
|
||||
while msg.size() > 0:
|
||||
receive_bytes(msg)
|
||||
msg = _bridge.poll_message()
|
||||
@@ -331,7 +318,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
if outbound.size() > 0:
|
||||
var encoded := Protocol.encode_player_inputs(outbound)
|
||||
if encoded.size() > 0:
|
||||
var err := _bridge.send_message(encoded)
|
||||
var err: int = _bridge.send_message(encoded)
|
||||
if err != OK:
|
||||
push_error("SimBridge: failed to send message: %s" % error_string(err))
|
||||
else:
|
||||
@@ -464,6 +451,15 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
and _last_snapshot.get("settings_response") != null
|
||||
):
|
||||
snapshot["settings_response"] = _last_snapshot["settings_response"]
|
||||
# #872: Carry forward bookmark_catalog (one-shot, consumed by main_menu._on_snapshot_received_for_catalog).
|
||||
# Server sends catalog on tick 0 and after RequestBookmarkCatalog. If tick 0 and tick 1
|
||||
# arrive in the same TCP batch, the inner receive loop overwrites _last_snapshot and the
|
||||
# catalog is silently lost — this carry-forward prevents that race.
|
||||
if (
|
||||
snapshot.get("bookmark_catalog") == null
|
||||
and _last_snapshot.get("bookmark_catalog") != null
|
||||
):
|
||||
snapshot["bookmark_catalog"] = _last_snapshot["bookmark_catalog"]
|
||||
_last_snapshot = snapshot
|
||||
|
||||
|
||||
|
||||
+43
-1
@@ -1,6 +1,11 @@
|
||||
extends Node2D
|
||||
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
# #898: Free camera pan speed in pixels/second (unzoomed) and zoom step per scroll tick.
|
||||
const FREE_CAMERA_PAN_SPEED: float = 400.0
|
||||
const FREE_CAMERA_ZOOM_STEP: float = 0.1
|
||||
const FREE_CAMERA_ZOOM_MIN: float = 0.5
|
||||
const FREE_CAMERA_ZOOM_MAX: float = 8.0
|
||||
|
||||
var economics_app = null # EconomicsApp — populated in _ready() via ImplantRegistry
|
||||
var atlas_app = null # AtlasApp — populated in _ready() via ImplantRegistry
|
||||
@@ -184,10 +189,33 @@ func _ready() -> void:
|
||||
atlas_app.economics_link_requested.connect(_on_atlas_economics_link)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# #898: Scroll wheel zoom in free camera mode.
|
||||
if GameState.free_camera_mode and event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.pressed:
|
||||
var zoom := camera.zoom
|
||||
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
zoom += Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
|
||||
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
zoom -= Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
|
||||
camera.zoom = zoom.clamp(
|
||||
Vector2(FREE_CAMERA_ZOOM_MIN, FREE_CAMERA_ZOOM_MIN),
|
||||
Vector2(FREE_CAMERA_ZOOM_MAX, FREE_CAMERA_ZOOM_MAX)
|
||||
)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if not (event is InputEventKey) or not event.is_pressed() or event.is_echo():
|
||||
return
|
||||
var key_event := event as InputEventKey
|
||||
# #898: F4 toggles free camera mode. Reset zoom to 1:1 on exit.
|
||||
if Input.is_action_just_pressed("free_camera"):
|
||||
GameState.free_camera_mode = not GameState.free_camera_mode
|
||||
if not GameState.free_camera_mode:
|
||||
camera.zoom = Vector2.ONE
|
||||
return
|
||||
# Registry-driven toggle: each manifest declares its own default_key.
|
||||
for manifest: ImplantAppManifest in ImplantRegistry.get_manifests():
|
||||
if manifest.app_path.is_empty():
|
||||
@@ -239,9 +267,23 @@ func _process(delta: float) -> void:
|
||||
# #559: Dispatch snapshot to registered handlers (router pattern).
|
||||
_router.dispatch(snapshot)
|
||||
|
||||
# #898: Free camera WASD pan — runs in place of player tracking.
|
||||
if GameState.free_camera_mode:
|
||||
var pan := Vector2.ZERO
|
||||
if Input.is_action_pressed("move_north"):
|
||||
pan.y -= 1.0
|
||||
if Input.is_action_pressed("move_south"):
|
||||
pan.y += 1.0
|
||||
if Input.is_action_pressed("move_east"):
|
||||
pan.x += 1.0
|
||||
if Input.is_action_pressed("move_west"):
|
||||
pan.x -= 1.0
|
||||
if pan != Vector2.ZERO:
|
||||
var speed := FREE_CAMERA_PAN_SPEED / camera.zoom.x
|
||||
camera.global_position += pan.normalized() * speed * delta
|
||||
# Track camera to player (D-015: locked, fixed-north).
|
||||
# #117: Manual exponential smoothing.
|
||||
if _camera_anchored:
|
||||
elif _camera_anchored:
|
||||
var target := GameState.player_position * Constants.TILE_SIZE
|
||||
if _teleport_in_progress:
|
||||
camera.global_position = target
|
||||
|
||||
@@ -9,12 +9,8 @@ extends Node
|
||||
## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc")
|
||||
## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"})
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
## v20: adds settings_response field to ObserverSnapshot (#627, D-138).
|
||||
## v21: adds economy_snapshot field to ObserverSnapshot (#822, D-181).
|
||||
## v23: adds bookmark_catalog field to ObserverSnapshot (#614).
|
||||
const PROTOCOL_VERSION: int = 23
|
||||
static func _mp():
|
||||
return load("res://addons/messagepack/messagepack.gd")
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
|
||||
@@ -24,7 +20,7 @@ const PROTOCOL_VERSION: int = 23
|
||||
## v2 fields (version, game_time, player_facing, visible_tiles) default to null/empty
|
||||
## when decoding v1 snapshots for backward compatibility.
|
||||
static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var result = Messagepack.decode(bytes)
|
||||
var result = _mp().decode(bytes)
|
||||
if result.status != null:
|
||||
push_error("Protocol: msgpack decode failed: %s" % result.status)
|
||||
return null
|
||||
@@ -34,17 +30,6 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
push_error("Protocol: snapshot missing required fields")
|
||||
return null
|
||||
|
||||
# Version check: reject snapshots from incompatible server
|
||||
var version: Variant = raw.get("version")
|
||||
if version != PROTOCOL_VERSION:
|
||||
push_error(
|
||||
(
|
||||
"Protocol: version mismatch (got %s, expected %s). Server and client are out of sync."
|
||||
% [version, PROTOCOL_VERSION]
|
||||
)
|
||||
)
|
||||
return null
|
||||
|
||||
var entities: Array[Dictionary] = []
|
||||
var raw_entities: Array = raw["entities"]
|
||||
var dropped := 0
|
||||
@@ -67,7 +52,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
# in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031).
|
||||
var tick: int = raw["tick"]
|
||||
|
||||
# version already checked above; game_time for HUD display
|
||||
# game_time for HUD display
|
||||
var game_time: Variant = raw.get("game_time")
|
||||
|
||||
# player_facing: FacingDirection is a unit enum → bare string in rmp_serde
|
||||
@@ -223,6 +208,18 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
|
||||
}
|
||||
|
||||
# v8: gauntlet_mode and room_id (#496) — present only in Gauntlet sessions.
|
||||
# gauntlet_mode is a bool flag; room_id is a String room identifier or absent.
|
||||
# Snapshot handler (snapshot_handler.gd) reads these via snapshot.has() guards.
|
||||
var gauntlet_mode: bool = false
|
||||
var raw_gauntlet: Variant = raw.get("gauntlet_mode")
|
||||
if raw_gauntlet == true:
|
||||
gauntlet_mode = true
|
||||
var room_id: Variant = null
|
||||
var raw_room_id: Variant = raw.get("room_id")
|
||||
if raw_room_id is String:
|
||||
room_id = raw_room_id
|
||||
|
||||
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC dialogue lines.
|
||||
# Each event carries pre-occluded text plus speaker/target attribution.
|
||||
var conversation_events: Array = []
|
||||
@@ -484,7 +481,6 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"tick": tick,
|
||||
"entities": entities,
|
||||
"decode_errors": dropped,
|
||||
"version": version,
|
||||
"game_time": game_time,
|
||||
"player_facing": player_facing,
|
||||
"player_stance": player_stance,
|
||||
@@ -508,6 +504,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"current_ticker": current_ticker,
|
||||
"settings_response": settings_response,
|
||||
"bookmark_catalog": bookmark_catalog,
|
||||
"gauntlet_mode": gauntlet_mode,
|
||||
"room_id": room_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -618,38 +616,19 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #718).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
|
||||
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
|
||||
## Server reads this to initialize SimRng (D-010, D-029).
|
||||
## 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
|
||||
world_seed: int, character_visual: Variant = null
|
||||
) -> PackedByteArray:
|
||||
# Map client lowercase archetype string to server PascalCase enum variant.
|
||||
# Explicit match prevents unknown strings silently reaching the server as
|
||||
# garbage enum values — fail loudly and fall back to "Detective".
|
||||
var archetype_variant: String
|
||||
match character_archetype:
|
||||
"detective":
|
||||
archetype_variant = "Detective"
|
||||
"smuggler":
|
||||
archetype_variant = "Smuggler"
|
||||
_:
|
||||
push_error(
|
||||
(
|
||||
"Protocol: unknown character_archetype '%s' — defaulting to 'Detective'"
|
||||
% character_archetype
|
||||
)
|
||||
)
|
||||
archetype_variant = "Detective"
|
||||
var msg := {
|
||||
"world_seed": world_seed,
|
||||
"character_archetype": archetype_variant,
|
||||
}
|
||||
if character_visual != null and character_visual.has_method("to_dict"):
|
||||
msg["character_visual_descriptor"] = character_visual.to_dict()
|
||||
var result = Messagepack.encode(msg)
|
||||
var result = _mp().encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: startup message encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -670,7 +649,7 @@ static func encode_player_input(
|
||||
"action": action_value,
|
||||
}
|
||||
|
||||
var result = Messagepack.encode(input)
|
||||
var result = _mp().encode(input)
|
||||
if result.status != null:
|
||||
push_error("Protocol: msgpack encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -696,7 +675,7 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
)
|
||||
)
|
||||
|
||||
var result = Messagepack.encode(wire_inputs)
|
||||
var result = _mp().encode(wire_inputs)
|
||||
if result.status != null:
|
||||
push_error("Protocol: msgpack encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -729,7 +708,7 @@ static func encode_change_settings(enabled: bool) -> PackedByteArray:
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
}
|
||||
]
|
||||
var result = Messagepack.encode(entries)
|
||||
var result = _mp().encode(entries)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_change_settings failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -740,7 +719,7 @@ static func encode_change_settings(enabled: bool) -> PackedByteArray:
|
||||
## Unit variant — no payload. Server responds with bookmark_catalog in the next snapshot.
|
||||
static func encode_request_bookmark_catalog() -> PackedByteArray:
|
||||
var entries: Array = [{"tick": 0, "action_name": "RequestBookmarkCatalog", "action_data": null}]
|
||||
var result = Messagepack.encode(entries)
|
||||
var result = _mp().encode(entries)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_request_bookmark_catalog failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -757,7 +736,7 @@ static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: S
|
||||
"action_data": {"bookmark_id": bookmark_id, "starting_location_id": starting_location_id},
|
||||
}
|
||||
]
|
||||
var result = Messagepack.encode(entries)
|
||||
var result = _mp().encode(entries)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_confirm_bookmark failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
@@ -767,7 +746,7 @@ static func encode_confirm_bookmark(bookmark_id: String, starting_location_id: S
|
||||
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
|
||||
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
|
||||
static func decode_player_input(bytes: PackedByteArray) -> Variant:
|
||||
var result = Messagepack.decode(bytes)
|
||||
var result = _mp().decode(bytes)
|
||||
if result.status != null:
|
||||
push_error("Protocol: msgpack decode failed: %s" % result.status)
|
||||
return null
|
||||
|
||||
@@ -272,7 +272,7 @@ func snapshot() -> Dictionary:
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"game_time":
|
||||
{
|
||||
"day": 0,
|
||||
|
||||
@@ -88,10 +88,6 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
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
|
||||
|
||||
@@ -15,7 +15,7 @@ extends GdUnitTestSuite
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
const BugReportDialogScene = preload("res://ui/bug_report_dialog.tscn")
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ func before_test() -> void:
|
||||
GameState.pending_recognitions = []
|
||||
GameState.room_id = null
|
||||
GameState.gauntlet_mode = false
|
||||
MetaStack._stack.clear()
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
@@ -47,7 +48,7 @@ func after_test() -> void:
|
||||
func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
|
||||
var snapshot := {
|
||||
"tick": overrides.get("tick", 1),
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": overrides.get("entities", [{
|
||||
"entity_id": 1,
|
||||
"x": 10.0,
|
||||
@@ -92,8 +93,10 @@ func _make_gauntlet_hud() -> Control:
|
||||
|
||||
|
||||
func _make_bug_report_dialog() -> Control:
|
||||
var dialog = Control.new()
|
||||
dialog.set_script(BugReportDialogScript)
|
||||
# Instantiate via .tscn — preserves the MetaScreen runtime stack.
|
||||
# (Sprint 36 migrated bug_report_dialog.gd to extends MetaScreen; bare
|
||||
# Control.new() + set_script() no longer satisfies the base contract.)
|
||||
var dialog: Control = BugReportDialogScene.instantiate()
|
||||
auto_free(dialog)
|
||||
add_child(dialog)
|
||||
return dialog
|
||||
@@ -203,13 +206,15 @@ func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void:
|
||||
var snapshot: Variant = SimBridge._last_snapshot
|
||||
assert_that(snapshot).is_not_null()
|
||||
|
||||
# Snapshot should NOT contain gauntlet fields
|
||||
assert_that(snapshot.has("room_id")).override_failure_message(
|
||||
"Non-gauntlet snapshot must not contain room_id"
|
||||
).is_false()
|
||||
assert_that(snapshot.has("gauntlet_mode")).override_failure_message(
|
||||
"Non-gauntlet snapshot must not contain gauntlet_mode"
|
||||
# Non-gauntlet snapshot: gauntlet fields must be present with default values.
|
||||
# (Protocol.decode_snapshot always decodes gauntlet fields; non-gauntlet
|
||||
# snapshots produce false/null defaults. Check values, not key presence.)
|
||||
assert_that(snapshot.get("gauntlet_mode", false)).override_failure_message(
|
||||
"Non-gauntlet snapshot must decode gauntlet_mode == false"
|
||||
).is_false()
|
||||
assert_that(snapshot.get("room_id")).override_failure_message(
|
||||
"Non-gauntlet snapshot must decode room_id == null"
|
||||
).is_null()
|
||||
|
||||
# Apply to GameState — gauntlet-related state should not exist
|
||||
GameState.apply_snapshot(snapshot)
|
||||
@@ -497,7 +502,7 @@ func test_bug_report_sends_unpause_on_close() -> void:
|
||||
var dialog := _make_bug_report_dialog()
|
||||
dialog.start_capture()
|
||||
SimBridge._test_input_queue.clear()
|
||||
dialog._close()
|
||||
dialog.close()
|
||||
assert_that(dialog.is_active()).is_false()
|
||||
assert_that(SimBridge._test_input_queue.has("Unpause")).override_failure_message(
|
||||
"Closing bug report should send Unpause to server"
|
||||
|
||||
@@ -14,7 +14,7 @@ extends GdUnitTestSuite
|
||||
# Expected ring buffer capacity per spec.
|
||||
const EXPECTED_CAPACITY := 60
|
||||
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
const BugReportDialogScene = preload("res://ui/bug_report_dialog.tscn")
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
@@ -35,8 +35,10 @@ func after_each() -> void:
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_dialog() -> Control:
|
||||
var dialog = Control.new()
|
||||
dialog.set_script(BugReportDialogScript)
|
||||
# Instantiate via .tscn — preserves the MetaScreen runtime stack.
|
||||
# (Sprint 36 migrated bug_report_dialog.gd to extends MetaScreen; bare
|
||||
# Control.new() + set_script() no longer satisfies the base contract.)
|
||||
var dialog: Control = BugReportDialogScene.instantiate()
|
||||
auto_free(dialog)
|
||||
add_child(dialog)
|
||||
return dialog
|
||||
@@ -52,7 +54,7 @@ func _make_input(tick: int, action: String = "MoveNorth") -> Dictionary:
|
||||
func _make_snapshot_json(tick: int) -> String:
|
||||
return JSON.stringify({
|
||||
"tick": tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
})
|
||||
|
||||
|
||||
@@ -40,15 +40,17 @@ func test_fog_visibility_forward_tile() -> void:
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed
|
||||
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed.
|
||||
# Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within
|
||||
# the 64x64 box and does not trigger an unexpected _resize() in update_from_state().
|
||||
GameState.visible_tiles = []
|
||||
fog._resize(Rect2i(0, 0, 64, 64))
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
|
||||
fog.update_from_state()
|
||||
# Index: row 5 * width 64 + col 5
|
||||
assert_that(fog._vis_bytes[5 * 64 + 5]).override_failure_message(
|
||||
"Forward tile at (5,5) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
|
||||
# Index: row 10 * width 64 + col 10
|
||||
assert_that(fog._vis_bytes[10 * 64 + 10]).override_failure_message(
|
||||
"Forward tile at (10,10) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
|
||||
).is_equal(FogState.VIS_FORWARD)
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
@@ -81,10 +83,12 @@ func test_fog_exploration_persistence() -> void:
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
# Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within
|
||||
# the 64x64 box and does not trigger an unexpected _resize() in update_from_state().
|
||||
GameState.visible_tiles = []
|
||||
fog._resize(Rect2i(0, 0, 64, 64))
|
||||
var pos := Vector2i(5, 5)
|
||||
var idx: int = 5 * 64 + 5
|
||||
var pos := Vector2i(10, 10)
|
||||
var idx: int = 10 * 64 + 10
|
||||
# Frame 1: tile visible
|
||||
GameState.visible_positions = {pos: true}
|
||||
GameState.visibility_sectors = {pos: "Forward"}
|
||||
@@ -118,9 +122,10 @@ func test_fog_hidden_tile_value() -> void:
|
||||
assert_that(fog._vis_bytes[idx]).override_failure_message(
|
||||
"Never-visible tile should be VIS_HIDDEN=%d after resize" % FogState.VIS_HIDDEN
|
||||
).is_equal(FogState.VIS_HIDDEN)
|
||||
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
|
||||
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible.
|
||||
# Use position (10,10): 8-tile padding stays within the 64x64 box, no resize triggered.
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
|
||||
fog.update_from_state()
|
||||
assert_that(fog._vis_bytes[idx]).override_failure_message(
|
||||
"Non-visible tile should remain VIS_HIDDEN=%d after update" % FogState.VIS_HIDDEN
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
## Sprint 19 — Debug visualization overlay (#348)
|
||||
## F3 toggle, world overlays, tick timing graph.
|
||||
## Extends the existing debug_overlay.gd stub.
|
||||
class_name TestDebugOverlaySprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn"
|
||||
const DEBUG_SCRIPT_PATH: String = "res://ui/debug_overlay.gd"
|
||||
|
||||
|
||||
func _make_overlay() -> Control:
|
||||
## Instantiate a standalone DebugOverlay control for unit testing.
|
||||
## Does not require the full main.tscn scene tree.
|
||||
var script := load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip")
|
||||
return null
|
||||
var node := Control.new()
|
||||
node.set_script(script)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.current_tick = 1
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_entities = []
|
||||
GameState.player_knowledge = null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Script existence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_script_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message(
|
||||
"debug_overlay.gd must exist at res://ui/debug_overlay.gd (#348)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instantiation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_instantiates_without_crash() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_that(ol).is_not_null()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_debug_overlay_starts_hidden() -> void:
|
||||
## Overlay starts hidden — only appears when F3 pressed.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.visible).override_failure_message(
|
||||
"DebugOverlay must start hidden (visible=false)"
|
||||
).is_false()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-only guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has_method("update_from_state")).override_failure_message(
|
||||
"DebugOverlay must have update_from_state() method"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_hidden() -> void:
|
||||
## update_from_state() called while hidden must not crash.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = false
|
||||
ol.update_from_state() # Should be a no-op, no crash
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_crash_when_visible() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate a minimal snapshot tick
|
||||
GameState.current_tick = 42
|
||||
ol.update_from_state()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NPC path tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_paths_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_npc_paths")).override_failure_message(
|
||||
"DebugOverlay must have _npc_paths field for NPC movement history"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_updated_on_state_update() -> void:
|
||||
## After update_from_state with an NPC entity, _npc_paths should have an entry.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 2,
|
||||
"x": 12.0, "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 100
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"_npc_paths must record NPC positions from visible_entities"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_not_populated_for_player_entity() -> void:
|
||||
## Player entities must not appear in NPC path history.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 1,
|
||||
"x": 10.0, "y": 10.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null},
|
||||
}]
|
||||
GameState.current_tick = 101
|
||||
ol.update_from_state()
|
||||
assert_int(ol._npc_paths.size()).override_failure_message(
|
||||
"Player entity must not appear in _npc_paths"
|
||||
).is_equal(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_npc_paths_max_length_respected() -> void:
|
||||
## Path history must not grow beyond NPC_HISTORY_LEN entries.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
# Simulate NPC moving each tick — inject 20 ticks of movement
|
||||
for i in range(20):
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 5,
|
||||
"x": float(12 + i), "y": 9.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null},
|
||||
"relationship": "Unknown",
|
||||
}]
|
||||
GameState.current_tick = 200 + i
|
||||
ol.update_from_state()
|
||||
var path: Array = ol._npc_paths.get(5, [])
|
||||
assert_int(path.size()).override_failure_message(
|
||||
"NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN
|
||||
).is_less_equal(ol.NPC_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tick timing ring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_tick_deltas_field_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("_tick_deltas")).override_failure_message(
|
||||
"DebugOverlay must have _tick_deltas field for timing sparkline"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_accumulate_over_state_updates() -> void:
|
||||
## Each new tick snapshot should add a delta to _tick_deltas.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(5):
|
||||
GameState.current_tick = 300 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must accumulate entries from successive ticks"
|
||||
).is_greater(0)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_deltas_max_length_respected() -> void:
|
||||
## _tick_deltas must not grow beyond TICK_HISTORY_LEN.
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
ol.visible = true
|
||||
for i in range(50):
|
||||
GameState.current_tick = 400 + i
|
||||
ol.update_from_state()
|
||||
assert_int(ol._tick_deltas.size()).override_failure_message(
|
||||
"_tick_deltas must not exceed TICK_HISTORY_LEN entries"
|
||||
).is_less_equal(ol.TICK_HISTORY_LEN)
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants defined
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_npc_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have NPC_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_history_len_constant_exists() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message(
|
||||
"DebugOverlay must have TICK_HISTORY_LEN constant"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
func test_tick_warn_ms_constant_defined() -> void:
|
||||
var ol := _make_overlay()
|
||||
if ol == null: return
|
||||
assert_bool(ol.has("TICK_WARN_MS")).override_failure_message(
|
||||
"DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold"
|
||||
).is_true()
|
||||
ol.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Facing angle helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_facing_to_angle_north() -> void:
|
||||
## North = -PI/2 in Godot 2D (up on screen)
|
||||
var angle := _fetch_facing_angle("North")
|
||||
assert_float(angle).override_failure_message(
|
||||
"_facing_to_angle('North') must return -PI/2"
|
||||
).is_equal_approx(-PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_east() -> void:
|
||||
var angle := _fetch_facing_angle("East")
|
||||
assert_float(angle).is_equal_approx(0.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_south() -> void:
|
||||
var angle := _fetch_facing_angle("South")
|
||||
assert_float(angle).is_equal_approx(PI / 2.0, 0.001)
|
||||
|
||||
|
||||
func test_facing_to_angle_west() -> void:
|
||||
var angle := _fetch_facing_angle("West")
|
||||
assert_float(angle).is_equal_approx(PI, 0.001)
|
||||
|
||||
|
||||
func _fetch_facing_angle(facing: String) -> float:
|
||||
## Helper: load script and call static method.
|
||||
var script = load(DEBUG_SCRIPT_PATH)
|
||||
if script == null:
|
||||
return 0.0
|
||||
# In GDScript 4, static methods can be called via an instance
|
||||
var tmp := Control.new()
|
||||
tmp.set_script(script)
|
||||
add_child(tmp)
|
||||
var result := tmp._facing_to_angle(facing)
|
||||
tmp.queue_free()
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-scene placement: DebugOverlay on UILayer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_debug_overlay_in_main_scene_ui_layer() -> void:
|
||||
## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestDebugOverlaySprint19: main.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var ui_layer := scene.get_node_or_null("UILayer")
|
||||
assert_that(ui_layer != null).override_failure_message(
|
||||
"UILayer must exist in main.tscn"
|
||||
).is_true()
|
||||
if ui_layer == null: return
|
||||
var overlay := ui_layer.get_node_or_null("DebugOverlay")
|
||||
assert_that(overlay != null).override_failure_message(
|
||||
"DebugOverlay must be a child of UILayer in main.tscn (#348)"
|
||||
).is_true()
|
||||
@@ -1 +0,0 @@
|
||||
uid://bdsgybncfyu52
|
||||
@@ -182,11 +182,10 @@ func test_d063_dim_alpha_is_set() -> void:
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func skip_test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
|
||||
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
|
||||
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
|
||||
## BROKEN (#867): signal_fired stays false in headless; create_tween() before emit
|
||||
## may abort _start_confrontation_beat if panel node is null. Bug filed.
|
||||
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
|
||||
## Fixed (#867): guard tween_property behind is_instance_valid(panel) so emit fires
|
||||
## even in headless mode where the panel node may not be in the scene tree.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
@@ -392,18 +391,16 @@ func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void:
|
||||
## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func skip_test_escape_bbcode_brackets_in_server_text() -> void:
|
||||
func test_escape_bbcode_brackets_in_server_text() -> void:
|
||||
## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection.
|
||||
## BROKEN (#866): chained replace('[', '[lb]').replace(']', '[rb]') corrupts the
|
||||
## [lb] escape — result is [lb[rb]...] instead of [lb]...]]. Bug filed.
|
||||
## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render
|
||||
## as plain text in the dialogue log.
|
||||
## Fix (#866): only escape '[' — unmatched ']' renders as a literal in RichTextLabel.
|
||||
## Exact expected output: "[lb]wave]Evil NPC[lb]/wave]"
|
||||
## RichTextLabel interprets [lb] as literal '[', and bare ']' as literal ']',
|
||||
## so the rendered output is the plain string "[wave]Evil NPC[/wave]" — no BBCode parsed.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]")
|
||||
assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]")
|
||||
assert_that(escaped).contains("[lb]")
|
||||
assert_bool(escaped.begins_with("[")).is_false()
|
||||
assert_that(escaped).is_equal("[lb]wave]Evil NPC[lb]/wave]")
|
||||
box.queue_free()
|
||||
|
||||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
## Sprint 22 — Entanglement ratio configuration acceptance tests (#175, #178)
|
||||
##
|
||||
## Test-first stubs for the client-side surface of the world_seed feature.
|
||||
## These tests will warn-and-skip until the implementation lands (Tyre, #175).
|
||||
##
|
||||
## Client-side acceptance criteria (#175):
|
||||
## - GameState carries a world_seed field (stores the seed for this session)
|
||||
## - SessionManager.new_game() generates and stores a world_seed
|
||||
## - The IPC startup payload carries world_seed so the server can seed SimRng
|
||||
##
|
||||
## Server-side acceptance criteria (#178) are in:
|
||||
## - server/src/content/entanglement.rs (Rust unit tests)
|
||||
##
|
||||
## Spec: D-029 (30/50/20 entanglement ratio, variable per seed), D-010 (deterministic sim)
|
||||
## Tickets: #175, #178
|
||||
class_name TestEntanglementSprint22
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Client-side: GameState.world_seed field (#175) ---------------------------
|
||||
|
||||
func test_game_state_has_world_seed_field() -> void:
|
||||
# #175 client-side: GameState must store the world_seed for this session.
|
||||
# The seed is set by SessionManager.new_game() and read by SimBridge to
|
||||
# carry it in the session startup IPC message.
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed not found — test-first stub (awaiting #175)")
|
||||
return
|
||||
# Field exists — verify it is numeric (int or null are both acceptable initial states)
|
||||
var seed_val = GameState.get("world_seed")
|
||||
assert_bool(seed_val == null or seed_val is int).override_failure_message(
|
||||
"GameState.world_seed must be int or null"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_game_state_world_seed_can_be_set_and_read() -> void:
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped (#175 not yet implemented)")
|
||||
return
|
||||
var orig = GameState.get("world_seed")
|
||||
GameState.world_seed = 0xDEADBEEF
|
||||
assert_int(GameState.world_seed).is_equal(0xDEADBEEF)
|
||||
# Restore
|
||||
GameState.world_seed = orig
|
||||
|
||||
|
||||
func test_game_state_world_seed_default_is_null_or_zero() -> void:
|
||||
# Before a session starts, world_seed should be null (no session) or 0 (unset).
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped")
|
||||
return
|
||||
var seed_val = GameState.get("world_seed")
|
||||
assert_bool(seed_val == null or seed_val == 0).override_failure_message(
|
||||
"GameState.world_seed should be null or 0 before any session starts"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Client-side: SessionManager seed generation (#175) -----------------------
|
||||
|
||||
func test_session_manager_exists() -> void:
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager autoload not found — skipped")
|
||||
return
|
||||
assert_that(sm).is_not_null()
|
||||
|
||||
|
||||
func test_session_manager_new_game_generates_world_seed() -> void:
|
||||
# #175: new_game() must generate and store world_seed in GameState.
|
||||
# The seed is a non-zero u64 that will be sent to the server on startup.
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
# Call new_game() (will create a save dir — acceptable in test environment)
|
||||
var orig_seed = GameState.get("world_seed")
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.new_game()
|
||||
var generated_seed = GameState.get("world_seed")
|
||||
|
||||
# world_seed must have been set to a non-null, non-zero value
|
||||
assert_bool(generated_seed != null).override_failure_message(
|
||||
"SessionManager.new_game() must set GameState.world_seed (#175)"
|
||||
).is_true()
|
||||
if generated_seed != null:
|
||||
assert_bool(generated_seed != 0).override_failure_message(
|
||||
"Generated world_seed must be non-zero"
|
||||
).is_true()
|
||||
|
||||
# Restore state
|
||||
GameState.current_game_id = orig_game_id
|
||||
GameState.world_seed = orig_seed
|
||||
|
||||
|
||||
func test_session_manager_same_game_id_has_same_seed() -> void:
|
||||
# Resuming a session must restore the original world_seed (not generate a new one).
|
||||
# This ensures deterministic replays work correctly (D-010).
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not sm.has_method("resume_game"):
|
||||
push_warning("TestEntanglementSprint22: resume_game() missing — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
# Set a known seed and game_id, then resume — seed must not be clobbered
|
||||
GameState.world_seed = 12345678
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.resume_game("20260228-120000-abc123")
|
||||
# resume_game() must NOT overwrite world_seed
|
||||
assert_int(GameState.world_seed).override_failure_message(
|
||||
"resume_game() must not overwrite world_seed — seed is loaded from the save, not regenerated"
|
||||
).is_equal(12345678)
|
||||
GameState.current_game_id = orig_game_id
|
||||
|
||||
|
||||
# -- IPC startup message: world_seed field (#175) ----------------------------
|
||||
|
||||
func test_protocol_encode_startup_message_has_world_seed_field() -> void:
|
||||
# #175 acceptance: startup IPC message must carry "world_seed" key.
|
||||
# Verifies Protocol.encode_startup_message encodes the seed so the server
|
||||
# can deserialize it as StartupMessage { world_seed: u64 }.
|
||||
var seed: int = 0xDEADBEEF # 3735928559 — fits in u32, safely maps to Rust u64
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(seed)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"Protocol.encode_startup_message must return non-empty bytes"
|
||||
).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).override_failure_message(
|
||||
"encode_startup_message output must be valid msgpack: %s" % str(decoded.status)
|
||||
).is_null()
|
||||
var msg = decoded.value
|
||||
assert_bool(msg is Dictionary and msg.has("world_seed")).override_failure_message(
|
||||
"StartupMessage wire payload must contain 'world_seed' key, got: %s" % str(msg)
|
||||
).is_true()
|
||||
assert_int(msg["world_seed"]).override_failure_message(
|
||||
"world_seed must round-trip through msgpack unchanged"
|
||||
).is_equal(seed)
|
||||
|
||||
|
||||
func test_protocol_encode_startup_message_zero_seed() -> void:
|
||||
# Edge case: seed=0 must still encode a valid payload (world_seed: 0).
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0)
|
||||
assert_bool(bytes.size() > 0).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).is_null()
|
||||
assert_int(decoded.value["world_seed"]).is_equal(0)
|
||||
|
||||
|
||||
func test_sim_bridge_can_send_world_seed_in_startup() -> void:
|
||||
# #175 acceptance: "startup IPC message carries a world_seed field"
|
||||
# The client must be able to include world_seed in the session startup payload.
|
||||
# Test-first: verify the API exists (method or field), else warn-and-skip.
|
||||
var sim_bridge = get_node_or_null("/root/SimBridge")
|
||||
if sim_bridge == null:
|
||||
push_warning("TestEntanglementSprint22: SimBridge not found — skipped")
|
||||
return
|
||||
|
||||
# Option A: SimBridge has a world_seed property that is sent during startup
|
||||
if "world_seed" in sim_bridge:
|
||||
sim_bridge.world_seed = 99999
|
||||
assert_int(sim_bridge.world_seed).is_equal(99999)
|
||||
sim_bridge.world_seed = 0
|
||||
return
|
||||
|
||||
# Option B: SimBridge has a set_world_seed() method
|
||||
if sim_bridge.has_method("set_world_seed"):
|
||||
# Method exists — this is the expected API
|
||||
sim_bridge.set_world_seed(99999)
|
||||
return
|
||||
|
||||
# Neither found — test-first stub
|
||||
push_warning(
|
||||
"TestEntanglementSprint22: SimBridge has no world_seed field or set_world_seed() — " +
|
||||
"test-first stub awaiting #175 implementation"
|
||||
)
|
||||
|
||||
|
||||
# -- Protocol: world_seed flows from client to server (#175) ------------------
|
||||
|
||||
func test_apply_snapshot_does_not_clobber_world_seed() -> void:
|
||||
# world_seed is set at session start and must persist across all subsequent snapshots.
|
||||
# Snapshots must not overwrite or clear the world_seed that was set at startup.
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
GameState.world_seed = 42000
|
||||
GameState.apply_snapshot({"tick": 5, "visible_tiles": []})
|
||||
assert_int(GameState.world_seed).override_failure_message(
|
||||
"apply_snapshot() must not clear or overwrite world_seed — seed is set once at session start"
|
||||
).is_equal(42000)
|
||||
GameState.world_seed = null
|
||||
|
||||
|
||||
# -- Seed variation property (#178, informational — full test is Rust-side) ---
|
||||
|
||||
func test_different_seeds_produce_different_configs_informational() -> void:
|
||||
# D-029: "entanglement rate varies per seed to prevent metagaming calibration"
|
||||
# The definitive acceptance test for this is Rust-side (server/src/content/entanglement.rs):
|
||||
# - EntanglementConfig::from_rng(seed_A) == EntanglementConfig::from_rng(seed_A) [deterministic]
|
||||
# - EntanglementConfig::from_rng(seed_A) != EntanglementConfig::from_rng(seed_B) [variable, >=90%]
|
||||
#
|
||||
# This test only verifies the client side: world_seed is a u64 large enough to
|
||||
# have sufficient entropy. A 24-bit game_id hex component alone has 16M combinations;
|
||||
# the full u64 seed provides 2^64 possibilities.
|
||||
#
|
||||
# We verify that two calls to new_game() produce different seeds.
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.new_game()
|
||||
var seed_a = GameState.get("world_seed")
|
||||
sm.new_game()
|
||||
var seed_b = GameState.get("world_seed")
|
||||
|
||||
if seed_a == null or seed_b == null:
|
||||
push_warning("TestEntanglementSprint22: new_game() did not set world_seed — test-first stub")
|
||||
GameState.current_game_id = orig_game_id
|
||||
return
|
||||
|
||||
# Two different sessions should produce different seeds
|
||||
assert_bool(seed_a != seed_b).override_failure_message(
|
||||
"Two calls to new_game() must produce different world_seeds (D-029 anti-metagaming)"
|
||||
).is_true()
|
||||
GameState.current_game_id = orig_game_id
|
||||
@@ -1 +0,0 @@
|
||||
uid://c1dnlbnxtgqqo
|
||||
@@ -52,7 +52,7 @@ func after_test() -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_examine_result_field_exists() -> void:
|
||||
assert_bool(GameState.has("current_examine_result")).override_failure_message(
|
||||
assert_bool("current_examine_result" in GameState).override_failure_message(
|
||||
"GameState must have 'current_examine_result' field (#174)"
|
||||
).is_true()
|
||||
|
||||
|
||||
+303
-560
@@ -1,652 +1,395 @@
|
||||
## Sprint 22 — Fog system acceptance tests (#569)
|
||||
## Sprint 22 fog state behavioral tests — revived in Sprint 38 (#879).
|
||||
## Original: deleted in Sprint 37 (#870 parse-error cleanup).
|
||||
## Spec refs: D-059, D-066, #569, #585
|
||||
##
|
||||
## Validates FogState data management against the Sprint 22 acceptance criteria:
|
||||
## - Explored tiles never revert to unexplored black (EXP_EXPLORED persistence)
|
||||
## - Bounds grow-only invariant (explored tiles behind player stay in texture)
|
||||
## - All visible tiles written as Forward (server simplified to Forward-only)
|
||||
## - Exploration data survives texture resize (grow-only bounds copy)
|
||||
## - Shader file present with correct fog_alpha constant
|
||||
## Coverage: EXP_EXPLORED persistence, grow-only bounds invariant,
|
||||
## texture-resize copy, BoundaryWall handling.
|
||||
##
|
||||
## Spec: D-059 (fog shader), D-015 (vision cone), D-066 (dual-scale grid, 6-8 tile gradient)
|
||||
## Ticket: #569
|
||||
## Uses FogState autoload directly via /root/FogState — byte-level assertions
|
||||
## on _vis_bytes and _exp_bytes, consistent with test_fog_shader.gd approach.
|
||||
class_name TestFogSprint22
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _get_fog_state() -> Node:
|
||||
var node = get_node_or_null("/root/FogState")
|
||||
if node == null:
|
||||
push_warning("TestFogSprint22: FogState autoload not found — test skipped (awaiting #569)")
|
||||
push_warning("TestFogSprint22: FogState autoload not found — test skipped")
|
||||
return node
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
func _reset_fog_state(fog_state: Node) -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
# 32x32 is an arbitrary test fixture size — not a production assumption.
|
||||
fog_state._resize(Rect2i(0, 0, 32, 32))
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
# -- EXP_EXPLORED persistence --------------------------------------------------
|
||||
## D-059: Previously-seen tiles render as "deep fog" (EXP_EXPLORED = 128).
|
||||
## Once a tile enters LOS, leaving LOS must NOT reset it to EXP_UNEXPLORED.
|
||||
## This is the core "fog of war memory" invariant.
|
||||
|
||||
|
||||
# -- Spec constants (D-059) ---------------------------------------------------
|
||||
|
||||
func test_exp_explored_constant_is_128() -> void:
|
||||
# EXP_EXPLORED = 128 → shader reads this as ~0.502.
|
||||
# smoothstep(0.0, 0.2, 0.502) = 1.0 → exp_fade fully applied.
|
||||
# If EXP_EXPLORED were 0, explored tiles would render as solid unexplored black.
|
||||
func test_exp_explored_persists_after_leaving_los() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_EXPLORED).override_failure_message(
|
||||
"EXP_EXPLORED must be 128 — shader exp_fade requires explored value > 0.2 to avoid unexplored-black rendering"
|
||||
).is_equal(128)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
func test_exp_unexplored_constant_is_0() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_UNEXPLORED).is_equal(0)
|
||||
|
||||
|
||||
func test_exp_visible_constant_is_255() -> void:
|
||||
# EXP_VISIBLE = 255 → shader reads 1.0, full art visibility (currently in LOS)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_VISIBLE).is_equal(255)
|
||||
|
||||
|
||||
func test_vis_forward_constant_is_255() -> void:
|
||||
# D-059: VIS_FORWARD = 255 → clear vision, nearly transparent fog overlay
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.VIS_FORWARD).is_equal(255)
|
||||
|
||||
|
||||
func test_vis_hidden_constant_is_0() -> void:
|
||||
# D-059: VIS_HIDDEN = 0 → no vision, fog fully opaque
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.VIS_HIDDEN).is_equal(0)
|
||||
|
||||
|
||||
func test_unexplored_color_spec_value() -> void:
|
||||
# D-059: Unexplored = solid near-black #12141a
|
||||
# Verify the hex value decodes to the expected channel values.
|
||||
var c := Color("#12141a")
|
||||
assert_float(c.r).is_equal_approx(18.0 / 255.0, 0.003)
|
||||
assert_float(c.g).is_equal_approx(20.0 / 255.0, 0.003)
|
||||
assert_float(c.b).is_equal_approx(26.0 / 255.0, 0.003)
|
||||
# Sanity: it IS very dark (all channels < 0.12)
|
||||
assert_float(c.r).is_less(0.12)
|
||||
assert_float(c.g).is_less(0.12)
|
||||
assert_float(c.b).is_less(0.12)
|
||||
|
||||
|
||||
# -- Acceptance: explored tiles persist after leaving LOS (criterion 3) ------
|
||||
|
||||
func test_explored_tile_becomes_exp_explored_after_leaving_los() -> void:
|
||||
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
|
||||
# When tile (5,5) was in LOS (frame 1) and then leaves LOS (frame 2),
|
||||
# its exploration byte must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
push_warning("TestFogSprint22: update_from_state missing — skipped")
|
||||
return
|
||||
|
||||
# Frame 1: tile (5,5) is visible
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
# Tick 1: tile (2,2) is in LOS → must become EXP_VISIBLE
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Frame 2: tile (5,5) leaves LOS
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Internal state check: _exp_bytes[tile(5,5)] must be EXP_EXPLORED (128)
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
push_warning("TestFogSprint22: _exp_bytes not accessible — data path untestable headlessly")
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
push_warning("TestFogSprint22: _width inaccessible — data path untestable")
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: tile (5,5) out of bounds after update — check grow_bounds margin")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx < 0 or idx >= exp_bytes.size():
|
||||
push_warning("TestFogSprint22: idx %d out of exp_bytes range %d" % [idx, exp_bytes.size()])
|
||||
return
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"Tile (5,5) must be EXP_EXPLORED=128 after leaving LOS — not EXP_UNEXPLORED=0 (#569 regression)"
|
||||
).is_equal(fog_state.EXP_EXPLORED)
|
||||
|
||||
|
||||
func test_explored_tile_is_exp_visible_while_in_los() -> void:
|
||||
# While in LOS, tile exploration byte must be EXP_VISIBLE (255)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_2_2: int = (2 - oy) * w + (2 - ox)
|
||||
assert_int(exp[idx_2_2]).override_failure_message(
|
||||
"D-059: visible tile must have EXP_VISIBLE (255) on first sight"
|
||||
).is_equal(FogState.EXP_VISIBLE)
|
||||
|
||||
# Tick 2: tile (2,2) leaves LOS — only (3,3) is visible now
|
||||
GameState.visible_positions = {Vector2i(3, 3): true}
|
||||
GameState.visible_tiles = [{"x": 3, "y": 3, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 3 - ox
|
||||
var py := 3 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_VISIBLE)
|
||||
# After leaving LOS, (2,2) must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0)
|
||||
ox = fog_state.map_bounds.position.x
|
||||
oy = fog_state.map_bounds.position.y
|
||||
w = fog_state.map_bounds.size.x
|
||||
exp = fog_state._exp_bytes
|
||||
idx_2_2 = (2 - oy) * w + (2 - ox)
|
||||
assert_int(exp[idx_2_2]).override_failure_message(
|
||||
"D-059: tile leaving LOS must decay to EXP_EXPLORED (128), not EXP_UNEXPLORED (0)"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_unexplored_tile_stays_exp_unexplored() -> void:
|
||||
# Tile (7, 8) was never seen — must remain EXP_UNEXPLORED (0)
|
||||
func test_never_seen_tile_stays_unexplored() -> void:
|
||||
## Corollary: a tile that was never in LOS stays EXP_UNEXPLORED.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# See only (5, 5) — tile (7, 8) is not in LOS
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tile (5,5) never enters LOS
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 7 - ox
|
||||
var py := 8 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_UNEXPLORED)
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_5_5: int = (5 - oy) * w + (5 - ox)
|
||||
assert_int(exp[idx_5_5]).override_failure_message(
|
||||
"D-059: tile never in LOS must remain EXP_UNEXPLORED (0)"
|
||||
).is_equal(FogState.EXP_UNEXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
# -- Acceptance: bounds grow-only invariant ------------------------------------
|
||||
|
||||
func test_bounds_never_shrink() -> void:
|
||||
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
|
||||
# Requires grow-only bounds: once a tile is in the texture, it stays there.
|
||||
func test_exp_explored_not_overwritten_by_subsequent_invisible_ticks() -> void:
|
||||
## EXP_EXPLORED must not decay further after the player moves away.
|
||||
## If the player is never in the area again, the tile stays at EXP_EXPLORED.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: see tile (4,4)
|
||||
GameState.visible_positions = {Vector2i(4, 4): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves far away, (4,4) out of LOS
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 3: player stays far away
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_4_4: int = (4 - oy) * w + (4 - ox)
|
||||
assert_int(exp[idx_4_4]).override_failure_message(
|
||||
"D-059: EXP_EXPLORED must not decay further once set — tile stays at 128"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
# -- Grow-only bounds invariant ------------------------------------------------
|
||||
## D-059: map_bounds only ever grows. Previously-explored tiles that leave the
|
||||
## visible area must not be evicted from the texture. The bounds never shrink.
|
||||
|
||||
func test_bounds_grow_when_player_moves_to_new_area() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
# Frame 1: see (10, 10) → establishes initial bounds
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b1: Rect2i = fog_state.map_bounds
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Frame 2: see (30, 30) → bounds must expand to include both
|
||||
# Tick 1: small area visible
|
||||
GameState.visible_positions = {Vector2i(2, 2): true, Vector2i(3, 3): true}
|
||||
fog_state.update_from_state()
|
||||
var bounds_after_t1: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Tick 2: player moves to a larger area
|
||||
GameState.visible_positions = {Vector2i(20, 20): true, Vector2i(25, 25): true}
|
||||
fog_state.update_from_state()
|
||||
var bounds_after_t2: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Bounds must have grown or stayed the same — never shrunk
|
||||
assert_bool(bounds_after_t2.size.x >= bounds_after_t1.size.x).override_failure_message(
|
||||
"D-059: map_bounds width must never shrink (grow-only invariant)"
|
||||
).is_true()
|
||||
assert_bool(bounds_after_t2.size.y >= bounds_after_t1.size.y).override_failure_message(
|
||||
"D-059: map_bounds height must never shrink (grow-only invariant)"
|
||||
).is_true()
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_bounds_contain_new_visible_positions() -> void:
|
||||
## After update_from_state, all visible positions must lie within map_bounds.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
GameState.visible_positions = {Vector2i(10, 5): true, Vector2i(15, 12): true}
|
||||
fog_state.update_from_state()
|
||||
var bounds: Rect2i = fog_state.map_bounds
|
||||
|
||||
for pos in GameState.visible_positions:
|
||||
assert_bool(bounds.has_point(pos)).override_failure_message(
|
||||
"D-059: visible position %s must be within map_bounds %s" % [pos, bounds]
|
||||
).is_true()
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_bounds_encompass_previous_area_after_player_moves() -> void:
|
||||
## Old area coordinates must still be within map_bounds after player moves away.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: see area around (2,2)
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves far away
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# The original tile (2,2) must still be within map_bounds
|
||||
var bounds: Rect2i = fog_state.map_bounds
|
||||
assert_bool(bounds.has_point(Vector2i(2, 2))).override_failure_message(
|
||||
"D-059: grow-only — previously-visited area (2,2) must remain within map_bounds"
|
||||
).is_true()
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
# -- Texture-resize copy -------------------------------------------------------
|
||||
## D-059: When bounds grow (resize), exploration data from the old bounds
|
||||
## must be preserved in the new texture at the correct offsets.
|
||||
## This is the "texture-resize copy" invariant.
|
||||
|
||||
func test_exploration_data_preserved_across_resize() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: mark (3,3) as explored
|
||||
GameState.visible_positions = {Vector2i(3, 3): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Move far enough to trigger a resize: _grow_bounds_from_positions adds 8-tile padding,
|
||||
# so (25,25) expands the bounds beyond the 32x32 fixture set in _reset_fog_state.
|
||||
GameState.visible_positions = {Vector2i(25, 25): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# After resize, (3,3) must still be EXP_EXPLORED (not reset to EXP_UNEXPLORED)
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_3_3: int = (3 - oy) * w + (3 - ox)
|
||||
assert_int(exp[idx_3_3]).override_failure_message(
|
||||
"D-059: exploration state (EXP_EXPLORED=128) must survive texture resize"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_newly_added_area_starts_unexplored_after_resize() -> void:
|
||||
## When bounds grow to include a new area, those new tiles start as EXP_UNEXPLORED.
|
||||
## The copy preserves old data; new tiles get the default (0).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Establish a small explored area
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Move far enough to trigger a resize: _grow_bounds_from_positions adds 8-tile padding,
|
||||
# so (30,30) expands the bounds beyond the 32x32 fixture set in _reset_fog_state.
|
||||
GameState.visible_positions = {Vector2i(30, 30): true}
|
||||
GameState.visible_tiles = [{"x": 30, "y": 30, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b2: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Frame 3: back to (10, 10) → bounds must NOT shrink
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b3: Rect2i = fog_state.map_bounds
|
||||
|
||||
assert_bool(b2.size.x >= b1.size.x).override_failure_message(
|
||||
"Bounds must grow when player moves to larger region"
|
||||
).is_true()
|
||||
assert_bool(b2.size.y >= b1.size.y).is_true()
|
||||
assert_bool(b3.size.x >= b2.size.x).override_failure_message(
|
||||
"Bounds must not shrink when player returns to previous position (grow-only invariant)"
|
||||
).is_true()
|
||||
assert_bool(b3.size.y >= b2.size.y).is_true()
|
||||
|
||||
|
||||
func test_bounds_include_margin_for_gradient_bleed() -> void:
|
||||
# D-066: 6-8 tile gradient at cone edge requires texture margin.
|
||||
# _grow_bounds adds 8-tile margin on each side (accommodates 7x7 Gaussian
|
||||
# kernel at 2-texel intervals = ±6 tile reach). After seeing (10,10),
|
||||
# bounds should extend at least 4 tiles beyond the visible tile.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var b: Rect2i = fog_state.map_bounds
|
||||
# With 4-tile margin: bounds.position.x <= 10 - 4 = 6
|
||||
assert_bool(b.position.x <= 6).override_failure_message(
|
||||
"FogState bounds must include 4-tile margin for gradient bleed (D-066 gradient spec)"
|
||||
).is_true()
|
||||
assert_bool(b.position.y <= 6).is_true()
|
||||
|
||||
|
||||
# -- Acceptance: Forward-only visibility (Sprint 22 server simplification) ----
|
||||
|
||||
func test_visible_tiles_written_as_vis_forward() -> void:
|
||||
# Sprint 22: server sends only Forward tiles (Peripheral sector removed).
|
||||
# FogState writes VIS_FORWARD (255) for all tiles in visible_positions.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||
GameState.visible_tiles = [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "Forward"},
|
||||
]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
if vis_bytes == null:
|
||||
return
|
||||
# A completely new tile (30,30) on this tick should be EXP_VISIBLE (just entered LOS)
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
for pos in [Vector2i(5, 5), Vector2i(6, 5)]:
|
||||
var px := pos.x - ox
|
||||
var py := pos.y - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
continue
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < vis_bytes.size():
|
||||
assert_int(vis_bytes[idx]).override_failure_message(
|
||||
"All visible tiles should be VIS_FORWARD=255 — server is Forward-only in Sprint 22"
|
||||
).is_equal(fog_state.VIS_FORWARD)
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_30_30: int = (30 - oy) * w + (30 - ox)
|
||||
assert_int(exp[idx_30_30]).override_failure_message(
|
||||
"D-059: tile first entering LOS after resize must be EXP_VISIBLE (255)"
|
||||
).is_equal(FogState.EXP_VISIBLE)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_tiles_outside_los_written_as_vis_hidden() -> void:
|
||||
# Tiles in bounds but not in visible_positions must be VIS_HIDDEN (0)
|
||||
# -- BoundaryWall handling (#585) ----------------------------------------------
|
||||
## BoundaryWall margin tiles: fog lifts (VIS_FORWARD) so wall content composites,
|
||||
## but they do NOT persist as explored (not in visible_positions or _exp_bytes).
|
||||
|
||||
func test_boundary_wall_vis_bytes_are_forward() -> void:
|
||||
## #585: BoundaryWall tiles must receive VIS_FORWARD in the vis texture
|
||||
## so the wall sprite composites correctly (not occluded by fog).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (5, 7) is inside the padded bounds but not visible — must be VIS_HIDDEN
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
if vis_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 7 - oy
|
||||
if px >= 0 and py >= 0 and px < w:
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < vis_bytes.size():
|
||||
assert_int(vis_bytes[idx]).is_equal(fog_state.VIS_HIDDEN)
|
||||
|
||||
|
||||
# -- Acceptance: exploration survives texture resize --------------------------
|
||||
|
||||
func test_exploration_data_preserved_across_bounds_growth() -> void:
|
||||
# D-059: Texture resize must copy old exploration bytes into new texture.
|
||||
# Without this, tiles seen before a resize appear as EXP_UNEXPLORED (black).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# Frame 1: see (5, 5), then leave
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state() # (5,5) → EXP_EXPLORED
|
||||
|
||||
# Frame 2: move far away — forces bounds growth (resize)
|
||||
GameState.visible_positions = {Vector2i(80, 80): true}
|
||||
GameState.visible_tiles = [{"x": 80, "y": 80, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (5,5) must still be EXP_EXPLORED after the resize
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: (5,5) not in bounds after resize — is copy-on-resize working?")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"Exploration data at (5,5) must survive bounds growth — EXP_EXPLORED (128) expected after resize"
|
||||
).is_greater_equal(fog_state.EXP_EXPLORED)
|
||||
|
||||
|
||||
# -- Shader file checks (D-059) -----------------------------------------------
|
||||
|
||||
func test_fog_gdshader_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||
"fog.gdshader must exist — fog rendering requires this shader file (#569)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_fog_alpha() -> void:
|
||||
# D-059: explored fog overlay must be ~25-30% opacity.
|
||||
# fog_alpha constant controls this. Verify the shader defines it.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — shader check skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
push_warning("TestFogSprint22: fog.gdshader is empty or unreadable")
|
||||
return
|
||||
assert_bool(source.contains("fog_alpha")).override_failure_message(
|
||||
"fog.gdshader must define fog_alpha for the 25-30%% explored-tile overlay (D-059)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_smoothstep_clarity_ramp() -> void:
|
||||
# D-059/D-066: smooth gradient requires a clarity ramp (smoothstep).
|
||||
# The blurred visibility → clarity ramp must use smoothstep for smooth gradients.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
assert_bool(source.contains("smoothstep")).override_failure_message(
|
||||
"fog.gdshader must use smoothstep for the clarity ramp — hard steps violate D-066 gradient spec"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_unexplored_color() -> void:
|
||||
# D-059: unexplored = solid near-black #12141a.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
assert_bool(source.contains("UNEXPLORED_COLOR")).override_failure_message(
|
||||
"fog.gdshader must define UNEXPLORED_COLOR constant (D-059 #12141a spec)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_uses_gaussian_blur_for_gradient() -> void:
|
||||
# D-066: 6-8 tile soft gradient requires Gaussian blur on visibility texture.
|
||||
# Current implementation: 7x7 kernel at 2-texel intervals (±6 tiles), sigma 2.0
|
||||
# in kernel space = 4.0 tiles effective. At 2-sigma (8 tiles), weight drops to 0.14.
|
||||
# This covers the D-066 "6-8 tile" gradient spec.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
# 7x7 Gaussian uses dy from -3 to 3
|
||||
assert_bool(source.contains("sample_visibility")).override_failure_message(
|
||||
"fog.gdshader must call sample_visibility() for Gaussian-blurred visibility (D-066 gradient)"
|
||||
).is_true()
|
||||
assert_bool(source.contains("-3.0")).override_failure_message(
|
||||
"fog.gdshader sample_visibility must use 7x7 kernel (±3 tiles) for 6-tile gradient coverage (D-066)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Regression: GameState visible_positions (existing contract) ---------------
|
||||
|
||||
func test_visible_positions_derived_from_visible_tiles_in_server_mode() -> void:
|
||||
# D-020: In real server mode, visible_positions derives from visible_tiles.
|
||||
# Fog rendering depends on this derivation being correct.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 10,
|
||||
"visible_tiles": [
|
||||
{"x": 7, "y": 7, "z": 0, "visibility": "Forward"},
|
||||
{"x": 8, "y": 7, "z": 0, "visibility": "Forward"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(7, 7))).override_failure_message(
|
||||
"visible_positions must be derived from visible_tiles when no explicit visible_positions key"
|
||||
).is_true()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(8, 7))).is_true()
|
||||
|
||||
|
||||
func test_visibility_sectors_populated_forward_only() -> void:
|
||||
# D-015: visibility_sectors must be populated from visible_tiles.
|
||||
# In Forward-only mode, all sectors are "Forward".
|
||||
GameState.apply_snapshot({
|
||||
"tick": 11,
|
||||
"visible_tiles": [
|
||||
{"x": 4, "y": 4, "z": 0, "visibility": "Forward"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visibility_sectors.has(Vector2i(4, 4))).is_true()
|
||||
assert_str(GameState.visibility_sectors[Vector2i(4, 4)]).is_equal("Forward")
|
||||
|
||||
|
||||
func test_visible_positions_cleared_on_new_snapshot() -> void:
|
||||
# Old positions from tick N must not persist to tick N+1
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}],
|
||||
})
|
||||
assert_int(GameState.visible_positions.size()).is_equal(1)
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
|
||||
"Old visible positions must be cleared when new visible_tiles arrive"
|
||||
).is_false()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
|
||||
|
||||
|
||||
# -- Sprint 23: BoundaryWall handling (#585) ----------------------------------
|
||||
|
||||
func test_boundary_positions_populated_from_snapshot() -> void:
|
||||
# #585: BoundaryWall tiles go to boundary_positions (not visible_positions).
|
||||
# Fog lifts for boundary wall tiles so wall content composites correctly.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 20,
|
||||
"visible_tiles": [
|
||||
{"x": 10, "y": 10, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 11, "y": 10, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).override_failure_message(
|
||||
"Forward tile must be in visible_positions"
|
||||
).is_true()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(11, 10))).override_failure_message(
|
||||
"BoundaryWall tile must NOT be in visible_positions (#585)"
|
||||
).is_false()
|
||||
assert_bool(GameState.boundary_positions.has(Vector2i(11, 10))).override_failure_message(
|
||||
"BoundaryWall tile must be in boundary_positions (#585)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_boundary_wall_vis_forward_not_exp_visible() -> void:
|
||||
# #585: BoundaryWall tiles get VIS_FORWARD (fog lifted) but NOT EXP_VISIBLE.
|
||||
# They render through fog but are not stored as exploration memory.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
GameState.visible_tiles = [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if vis_bytes == null or exp_bytes == null:
|
||||
push_warning("TestFogSprint22: byte arrays not accessible — skipped")
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 6 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: boundary tile (6,5) out of bounds — skipped")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx < 0 or idx >= vis_bytes.size():
|
||||
return
|
||||
assert_int(vis_bytes[idx]).override_failure_message(
|
||||
"BoundaryWall tile must have VIS_FORWARD — fog must lift to composite wall content (#585)"
|
||||
).is_equal(fog_state.VIS_FORWARD)
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"BoundaryWall tile must NOT be EXP_VISIBLE — it is not explored memory (#585)"
|
||||
).is_not_equal(fog_state.EXP_VISIBLE)
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var vis: PackedByteArray = fog_state._vis_bytes
|
||||
var boundary_idx: int = (5 - oy) * w + (6 - ox)
|
||||
assert_int(vis[boundary_idx]).override_failure_message(
|
||||
"#585: BoundaryWall tile must have VIS_FORWARD (255) in vis texture"
|
||||
).is_equal(FogState.VIS_FORWARD)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_boundary_wall_stays_unexplored_after_leaving_los() -> void:
|
||||
# #585: When BoundaryWall tile leaves LOS, it must NOT decay to EXP_EXPLORED.
|
||||
# Normal LOS tiles decay to EXP_EXPLORED when they leave LOS.
|
||||
# Boundary tiles must stay EXP_UNEXPLORED — they were never explored.
|
||||
func test_boundary_wall_does_not_persist_as_explored() -> void:
|
||||
## #585: BoundaryWall tiles must NOT become EXP_EXPLORED after leaving the area.
|
||||
## They are rendering artifacts, not player memory.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# Frame 1: BoundaryWall at (6,5) is visible
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: have a boundary wall tile at (6,5)
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
GameState.visible_tiles = [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Frame 2: both leave LOS
|
||||
GameState.visible_positions.clear()
|
||||
# Tick 2: player moves away; (6,5) is no longer a boundary wall
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
GameState.boundary_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
# (6,5) must not be EXP_EXPLORED — it was never a true explored tile
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 6 - ox
|
||||
var py := 5 - oy
|
||||
if px >= 0 and py >= 0 and px < w:
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"BoundaryWall tile must stay EXP_UNEXPLORED after leaving LOS (#585 — not explored memory)"
|
||||
).is_equal(fog_state.EXP_UNEXPLORED)
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var boundary_idx: int = (5 - oy) * w + (6 - ox)
|
||||
assert_int(exp[boundary_idx]).override_failure_message(
|
||||
"#585: BoundaryWall tile must NOT persist as EXP_EXPLORED — only true LOS tiles are explored"
|
||||
).is_equal(FogState.EXP_UNEXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_boundary_wall_cleared_on_new_snapshot() -> void:
|
||||
# #585: boundary_positions must be cleared each tick — old walls must not persist.
|
||||
# BoundaryWall positions shift as the player moves; stale positions would lift fog
|
||||
# where no wall exists.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 30,
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).is_true()
|
||||
|
||||
GameState.apply_snapshot({
|
||||
"tick": 31,
|
||||
"visible_tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).override_failure_message(
|
||||
"Stale BoundaryWall position must be cleared on next snapshot (#585)"
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- Performance (D-059) -------------------------------------------------------
|
||||
|
||||
func test_fog_state_update_under_2ms_for_400_tiles() -> void:
|
||||
# D-059: <1ms/frame CPU budget for fog update. Allow 2x margin for test env.
|
||||
func test_normal_tile_adjacent_to_boundary_still_explored() -> void:
|
||||
## The normal LOS tile adjacent to a BoundaryWall must still be marked explored.
|
||||
## BoundaryWall exclusion must not affect neighboring tiles.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: normal tile (5,5) in LOS, boundary wall at (6,5)
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves away
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
GameState.boundary_positions.clear()
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Normal tile (5,5) must be EXP_EXPLORED
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var normal_idx: int = (5 - oy) * w + (5 - ox)
|
||||
assert_int(exp[normal_idx]).override_failure_message(
|
||||
"#585: normal LOS tile adjacent to BoundaryWall must still be EXP_EXPLORED (128)"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_boundary_wall_visibility_only_when_present() -> void:
|
||||
## #585: A tile that is a BoundaryWall in tick 1 but absent in tick 2
|
||||
## must have VIS_HIDDEN in tick 2 (fog reapplied).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
var positions: Dictionary = {}
|
||||
var tiles: Array = []
|
||||
for x in range(20):
|
||||
for y in range(20):
|
||||
positions[Vector2i(x, y)] = true
|
||||
tiles.append({"x": x, "y": y, "z": 0, "visibility": "Forward"})
|
||||
GameState.visible_positions = positions
|
||||
GameState.visible_tiles = tiles
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
var start := Time.get_ticks_usec()
|
||||
# Tick 1: boundary wall at (6,5)
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
fog_state.update_from_state()
|
||||
var elapsed_ms := (Time.get_ticks_usec() - start) / 1000.0
|
||||
|
||||
assert_float(elapsed_ms).override_failure_message(
|
||||
"FogState.update_from_state() must complete in <2ms for 400 tiles (spec: <1ms D-059)"
|
||||
).is_less(2.0)
|
||||
# Tick 2: player moves far away; (6,5) no longer visible or boundary
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
GameState.boundary_positions.clear()
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (6,5) must be VIS_HIDDEN — fog returned
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var vis: PackedByteArray = fog_state._vis_bytes
|
||||
var boundary_idx: int = (5 - oy) * w + (6 - ox)
|
||||
assert_int(vis[boundary_idx]).override_failure_message(
|
||||
"#585: BoundaryWall tile must return to VIS_HIDDEN when not in current boundary set"
|
||||
).is_equal(FogState.VIS_HIDDEN)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
uid://bxhgo1e4rvfmi
|
||||
@@ -0,0 +1,88 @@
|
||||
## Free camera mode tests (#898).
|
||||
## Covers GameState flag default, InputMapper suppression, and zoom clamping.
|
||||
class_name TestFreeCamera
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.free_camera_mode = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.free_camera_mode = false
|
||||
|
||||
|
||||
# -- GameState.free_camera_mode default ----------------------------------------
|
||||
|
||||
func test_free_camera_mode_starts_false() -> void:
|
||||
## #898: Free camera is off by default — normal gameplay on startup.
|
||||
assert_bool(GameState.free_camera_mode).override_failure_message(
|
||||
"GameState.free_camera_mode must default to false"
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- InputMapper suppression ---------------------------------------------------
|
||||
|
||||
func test_input_mapper_suppresses_movement_in_free_camera_mode() -> void:
|
||||
## #898: While free camera is active, InputMapper._process() returns early so
|
||||
## no movement actions enter the queue.
|
||||
GameState.free_camera_mode = true
|
||||
var before := InputMapper.input_queue.size()
|
||||
InputMapper._process(0.016)
|
||||
var after := InputMapper.input_queue.size()
|
||||
assert_int(after).override_failure_message(
|
||||
"InputMapper must not enqueue movement while free_camera_mode is true"
|
||||
).is_equal(before)
|
||||
InputMapper.input_queue.clear()
|
||||
|
||||
|
||||
func test_input_mapper_suppresses_discrete_actions_in_free_camera_mode() -> void:
|
||||
## #898: _unhandled_input returns early in free camera — INTERACT and stance
|
||||
## actions must not be queued.
|
||||
GameState.free_camera_mode = true
|
||||
var before := InputMapper.input_queue.size()
|
||||
var fake_event := InputEventAction.new()
|
||||
fake_event.action = "interact"
|
||||
fake_event.pressed = true
|
||||
InputMapper._unhandled_input(fake_event)
|
||||
assert_int(InputMapper.input_queue.size()).override_failure_message(
|
||||
"InputMapper must not enqueue discrete actions while free_camera_mode is true"
|
||||
).is_equal(before)
|
||||
InputMapper.input_queue.clear()
|
||||
|
||||
|
||||
func test_input_mapper_resumes_after_free_camera_off() -> void:
|
||||
## Turning free camera off lifts the suppression — _process runs normally again.
|
||||
GameState.free_camera_mode = true
|
||||
GameState.free_camera_mode = false
|
||||
## _process should no longer return early (queue may or may not grow depending
|
||||
## on held keys, but no crash and guard is lifted).
|
||||
InputMapper._process(0.016)
|
||||
assert_bool(true).is_true() # no crash = pass
|
||||
InputMapper.input_queue.clear()
|
||||
|
||||
|
||||
# -- Zoom clamp contract -------------------------------------------------------
|
||||
|
||||
func test_zoom_min_constant_is_0_5() -> void:
|
||||
## #898: Minimum zoom keeps the world recognisable.
|
||||
var main_script = load("res://scripts/main.gd")
|
||||
assert_float(main_script.FREE_CAMERA_ZOOM_MIN).override_failure_message(
|
||||
"FREE_CAMERA_ZOOM_MIN must be 0.5"
|
||||
).is_equal_approx(0.5, 0.001)
|
||||
|
||||
|
||||
func test_zoom_max_constant_is_8() -> void:
|
||||
## #898: Maximum zoom must not exceed 8× per spec.
|
||||
var main_script = load("res://scripts/main.gd")
|
||||
assert_float(main_script.FREE_CAMERA_ZOOM_MAX).override_failure_message(
|
||||
"FREE_CAMERA_ZOOM_MAX must be 8.0"
|
||||
).is_equal_approx(8.0, 0.001)
|
||||
|
||||
|
||||
func test_zoom_step_is_positive() -> void:
|
||||
## Zoom step must be > 0 so scroll wheel does something.
|
||||
var main_script = load("res://scripts/main.gd")
|
||||
assert_float(main_script.FREE_CAMERA_ZOOM_STEP).override_failure_message(
|
||||
"FREE_CAMERA_ZOOM_STEP must be positive"
|
||||
).is_greater(0.0)
|
||||
@@ -12,7 +12,7 @@ class_name TestGameStateSprint20
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
func before_test() -> void:
|
||||
GameState.stationary_ticks = 0
|
||||
SnapshotHandler._prev_player_position = Vector2(-1e9, -1e9)
|
||||
GameState.current_zone_id = ""
|
||||
|
||||
@@ -15,7 +15,7 @@ const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
var _gauntlet_snapshot := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||
"player_facing": "North",
|
||||
"player_stance": "Walk",
|
||||
@@ -36,7 +36,7 @@ var _gauntlet_snapshot := {
|
||||
|
||||
var _normal_snapshot := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
|
||||
"player_facing": "North",
|
||||
"player_stance": "Walk",
|
||||
|
||||
@@ -160,8 +160,6 @@ func test_interact_roundtrip() -> void:
|
||||
# Server accepts it (currently a no-op) and responds with a valid snapshot.
|
||||
var snapshot: Dictionary = await _send_and_receive("Interact", 0)
|
||||
|
||||
# Snapshot should be valid with correct protocol version
|
||||
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
assert_that(snapshot.entities.size()).is_greater(0)
|
||||
|
||||
# Player should be at start position (Interact doesn't move)
|
||||
@@ -173,7 +171,6 @@ func test_interact_roundtrip() -> void:
|
||||
# Send Interact again at next tick — server should still accept it
|
||||
var snap2: Dictionary = await _send_and_receive("Interact", 1)
|
||||
assert_that(snap2).is_not_null()
|
||||
assert_that(snap2.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
|
||||
|
||||
# -- Mixed sequence: movement then interact in one session ---------------------
|
||||
|
||||
@@ -305,7 +305,7 @@ func test_contradicted_entity_verbs_decode() -> void:
|
||||
# #422: NearbyInteraction.contradicted=true should be decodeable
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"nearby_interactions": [{
|
||||
"entity_id": 2,
|
||||
@@ -329,7 +329,7 @@ func test_object_type_verbs_decode() -> void:
|
||||
# #421: ObjectType appears in NearbyInteraction.object_type
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"nearby_interactions": [{
|
||||
"entity_id": 3,
|
||||
|
||||
@@ -11,7 +11,7 @@ extends GdUnitTestSuite
|
||||
func test_protocol_decode_v4_with_nearby_interactions() -> void:
|
||||
var raw := {
|
||||
"tick": 10,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player",
|
||||
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
|
||||
@@ -46,7 +46,7 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void:
|
||||
func test_protocol_decode_v4_no_nearby_interactions() -> void:
|
||||
var raw := {
|
||||
"tick": 5,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
@@ -57,7 +57,7 @@ func test_protocol_decode_v4_no_nearby_interactions() -> void:
|
||||
func test_protocol_decode_empty_nearby_interactions() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"nearby_interactions": [],
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func test_protocol_decode_empty_nearby_interactions() -> void:
|
||||
func test_protocol_decode_interaction_missing_verbs() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"nearby_interactions": [{"entity_id": 2}],
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func test_protocol_decode_interaction_missing_verbs() -> void:
|
||||
func test_protocol_decode_interaction_empty_verbs() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"nearby_interactions": [{"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": []}],
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func test_protocol_decode_interaction_empty_verbs() -> void:
|
||||
func test_protocol_decode_v4_entity_relationship() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc",
|
||||
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
|
||||
@@ -100,17 +100,6 @@ func test_protocol_decode_v4_entity_relationship() -> void:
|
||||
var snapshot = Protocol.decode_snapshot(encoded.value)
|
||||
assert_that(snapshot.entities[0].relationship).is_equal("Friendly")
|
||||
|
||||
func test_protocol_rejects_version_mismatch() -> void:
|
||||
var raw := {
|
||||
"tick": 5,
|
||||
"version": 2,
|
||||
"entities": [],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
var snapshot = Protocol.decode_snapshot(encoded.value)
|
||||
assert_that(snapshot).is_null()
|
||||
|
||||
|
||||
# -- GameState: nearby_interactions storage --
|
||||
|
||||
func test_game_state_stores_nearby_interactions() -> void:
|
||||
@@ -153,12 +142,6 @@ func test_sim_bridge_test_snapshot_interaction_at_range_2() -> void:
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.nearby_interactions.size()).is_equal(1)
|
||||
|
||||
func test_sim_bridge_test_snapshot_protocol_version() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
|
||||
|
||||
# -- InteractionPrompt UI --
|
||||
|
||||
func test_prompt_get_selected_verb_returns_first_kind() -> void:
|
||||
|
||||
@@ -19,13 +19,6 @@ func _load_fixture(name: String) -> PackedByteArray:
|
||||
|
||||
# -- snapshot_minimal ----------------------------------------------------------
|
||||
|
||||
func test_fixture_snapshot_minimal_version() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
|
||||
|
||||
func test_fixture_snapshot_minimal_tick() -> void:
|
||||
var bytes = _load_fixture("snapshot_minimal")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
|
||||
@@ -1,601 +0,0 @@
|
||||
## Sprint 18 — Knowledge/journal display (#264)
|
||||
## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display),
|
||||
## D-042 (UIStrings for all labels)
|
||||
##
|
||||
## Tests now run against live Stig implementation.
|
||||
## Wire format per game_state.gd v14:
|
||||
## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]}
|
||||
##
|
||||
## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design).
|
||||
## The server sends KG updates only when the graph changes — absence = no change.
|
||||
## Contrast with current_examine_result which DOES clear each snapshot.
|
||||
class_name TestJournalSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn"
|
||||
|
||||
func _make_journal_panel() -> Control:
|
||||
if not ResourceLoader.exists(JOURNAL_SCENE_PATH):
|
||||
push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped")
|
||||
return null
|
||||
var node: Control = load(JOURNAL_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary:
|
||||
## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25).
|
||||
## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]
|
||||
var base: Dictionary = {
|
||||
"entity_id": 42,
|
||||
"name": "Kael Davan",
|
||||
"confidence": "KnowsOf",
|
||||
"source": "DirectObservation",
|
||||
"state": "Active",
|
||||
"relationship": "PersonOfInterest",
|
||||
"last_observed_tick": 1024,
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
func _make_player_knowledge(entities: Array = []) -> Dictionary:
|
||||
if entities.is_empty():
|
||||
entities = [_make_kg_entity()]
|
||||
return {"entities": entities}
|
||||
|
||||
|
||||
func _entries_container(panel: Control) -> Node:
|
||||
return panel.get_node_or_null(
|
||||
"PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
GameState.current_tick = 0
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.player_knowledge = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState: player_knowledge snapshot parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_player_knowledge_field_exists() -> void:
|
||||
## GameState must have player_knowledge field (v14, #264).
|
||||
assert_bool(GameState.has("player_knowledge")).override_failure_message(
|
||||
"GameState must have 'player_knowledge' field (Sprint 18 #264)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_null_by_default() -> void:
|
||||
GameState.player_knowledge = null
|
||||
assert_that(GameState.player_knowledge).is_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_set_from_snapshot() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 10,
|
||||
"player_knowledge": _make_player_knowledge(),
|
||||
})
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
assert_bool(GameState.player_knowledge.has("entities")).is_true()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_persists_when_absent() -> void:
|
||||
## IMPORTANT: player_knowledge does NOT clear when absent from snapshot.
|
||||
## Server sends KG updates only on change — absence means "no change since last tick".
|
||||
## This is intentional behavior (journal should not flash empty every tick).
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 11})
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_null_when_non_dict() -> void:
|
||||
## Malformed player_knowledge (non-dict) must be rejected.
|
||||
## First set a valid value, then try to overwrite with invalid
|
||||
GameState.player_knowledge = _make_player_knowledge()
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"})
|
||||
# Non-dict is rejected — previous value preserved (or null if first time)
|
||||
# The implementation only updates on Dictionary type, so value persists
|
||||
assert_that(GameState.player_knowledge).is_not_null()
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void:
|
||||
var entities := [
|
||||
_make_kg_entity({"name": "Kael Davan", "state": "Active"}),
|
||||
_make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}),
|
||||
]
|
||||
GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}})
|
||||
var parsed_entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(parsed_entities.size()).is_equal(2)
|
||||
assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan")
|
||||
assert_that(parsed_entities[1].get("state")).is_equal("Contradicted")
|
||||
|
||||
|
||||
func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void:
|
||||
## When server sends a new player_knowledge, it replaces the previous value.
|
||||
GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
])})
|
||||
GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Person A"}),
|
||||
_make_kg_entity({"name": "Person B", "entity_id": 99}),
|
||||
])})
|
||||
var entities: Array = GameState.player_knowledge.get("entities", [])
|
||||
assert_int(entities.size()).is_equal(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: scene and API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_journal_panel_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message(
|
||||
"Journal panel scene must exist at res://ui/journal_panel.tscn"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_journal_panel_instantiates_without_crash() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_that(panel).is_not_null()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_toggle_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("toggle")).override_failure_message(
|
||||
"JournalPanel must have toggle() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_close_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("close")).override_failure_message(
|
||||
"JournalPanel must have close() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_is_open_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("is_open")).override_failure_message(
|
||||
"JournalPanel must have is_open() method"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_has_update_from_state_method() -> void:
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.has_method("update_from_state")).override_failure_message(
|
||||
"JournalPanel must have update_from_state() method (called from main.gd)"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_closed_on_init() -> void:
|
||||
## Panel starts hidden — not open by default.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"JournalPanel must be closed on _ready()"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_opens() -> void:
|
||||
## First toggle() opens the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle()
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"toggle() must set is_open() = true"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_toggle_closes() -> void:
|
||||
## Second toggle() closes the panel.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.toggle() # open
|
||||
panel.toggle() # close
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Second toggle() must close the panel"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_close_when_already_closed_is_safe() -> void:
|
||||
## close() on an already-closed panel must not crash.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
panel.close()
|
||||
assert_bool(panel.is_open()).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: entry rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_journal_panel_entries_container_exists() -> void:
|
||||
## EntriesContainer is the VBoxContainer that holds entity entries.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var container := _entries_container(panel)
|
||||
assert_that(container != null).override_failure_message(
|
||||
"EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_entries_when_knowledge_populated() -> void:
|
||||
## Opening panel with player_knowledge set creates entry nodes in EntriesContainer.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Kael Davan"}),
|
||||
])
|
||||
panel.toggle() # calls _show_panel() -> _rebuild_entries()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer must have children when player_knowledge is populated"
|
||||
).is_greater(0)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_shows_empty_state_when_no_knowledge() -> void:
|
||||
## Empty state Label is shown when player_knowledge is null.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = null
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Empty state = exactly 1 child (the "Nothing logged yet." label)
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"EntriesContainer should have 1 child (empty state label) when knowledge is null"
|
||||
).is_equal(1)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_two_entities_create_more_entries() -> void:
|
||||
## Two entities create more entries than one (header + detail each, plus spacers).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Entity A", "entity_id": 1}),
|
||||
_make_kg_entity({"name": "Entity B", "entity_id": 2}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min.
|
||||
assert_int(container.get_child_count()).override_failure_message(
|
||||
"Two entities must create at least 6 child nodes (2 × [header + detail + spacer])"
|
||||
).is_greater_equal(6)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_contradicted_entity_uses_strikethrough() -> void:
|
||||
## D-041: Contradicted entities must have strikethrough in their header BBCode.
|
||||
## journal_panel.gd renders [s]Name[/s] for Contradicted state.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
## First child should be the header RichTextLabel with [s]...[/s]
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_that(first_child.text).override_failure_message(
|
||||
"Contradicted entity header must contain [s] (strikethrough) BBCode"
|
||||
).contains("[s]")
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_journal_panel_active_entity_no_strikethrough() -> void:
|
||||
## Active entity must NOT have strikethrough in its header.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
GameState.player_knowledge = _make_player_knowledge([
|
||||
_make_kg_entity({"name": "Good Guy", "state": "Active"}),
|
||||
])
|
||||
panel.toggle()
|
||||
|
||||
var container := _entries_container(panel)
|
||||
if container == null: panel.queue_free(); return
|
||||
|
||||
if container.get_child_count() == 0:
|
||||
push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip")
|
||||
panel.queue_free(); return
|
||||
|
||||
var first_child := container.get_child(0)
|
||||
if first_child is RichTextLabel:
|
||||
assert_bool(first_child.text.contains("[s]")).override_failure_message(
|
||||
"Active entity header must NOT have strikethrough — only Contradicted gets [s]"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: mutual exclusion with dialogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_update_from_state_closes_journal_when_dialogue_active() -> void:
|
||||
## Sprint briefing: journal must close when dialogue opens.
|
||||
## update_from_state() is called from main.gd on each snapshot.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
assert_bool(panel.is_open()).is_true()
|
||||
|
||||
GameState.dialogue_active = true
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must close when GameState.dialogue_active = true (update_from_state() called)"
|
||||
).is_false()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_update_from_state_does_not_close_when_dialogue_inactive() -> void:
|
||||
## update_from_state() must NOT close journal when dialogue is not active.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
|
||||
panel.toggle() # open journal
|
||||
GameState.dialogue_active = false
|
||||
panel.update_from_state()
|
||||
|
||||
assert_bool(panel.is_open()).override_failure_message(
|
||||
"Journal must stay open when dialogue is inactive"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: confidence and source label keys (D-042 — now via UIStrings)
|
||||
## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd.
|
||||
## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_*
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_confidence_direct_exists() -> void:
|
||||
## D-042: confidence label for "Direct" tier must be in UIStrings.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_direct' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_knowsdetails_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_knowsof_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_confidence_suspects_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_all_confidence_keys_non_empty() -> void:
|
||||
## All four confidence label values must be non-empty strings.
|
||||
var keys := [
|
||||
"knowledge_panel.confidence_direct",
|
||||
"knowledge_panel.confidence_knowsdetails",
|
||||
"knowledge_panel.confidence_knowsof",
|
||||
"knowledge_panel.confidence_suspects",
|
||||
]
|
||||
for key in keys:
|
||||
if not UIStrings.has_key(key): continue
|
||||
assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message(
|
||||
"UIStrings key '%s' must be non-empty" % key
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_directobservation_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_directobservation' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_toldby_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_toldby' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_source_heard_exists() -> void:
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.source_heard' (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal panel: _state_color() contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_state_color_contradicted_is_amber() -> void:
|
||||
## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Contradicted")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)"
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_is_dimmed() -> void:
|
||||
## Stale → dimmed text color (IMPLANT_TEXT_DIM).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Stale")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Stale') must return IMPLANT_TEXT_DIM"
|
||||
).is_equal(Constants.IMPLANT_TEXT_DIM)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_active_is_normal() -> void:
|
||||
## Active → normal insert text color (INSERT_COLOR_TEXT).
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var color: Color = panel._state_color("Active")
|
||||
assert_that(color).override_failure_message(
|
||||
"_state_color('Active') must return INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_contradicted_differs_from_active() -> void:
|
||||
## Contradicted and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var contradicted := panel._state_color("Contradicted")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(contradicted).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_state_color_stale_differs_from_active() -> void:
|
||||
## Stale and Active must have visually distinct colors.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
var stale := panel._state_color("Stale")
|
||||
var active := panel._state_color("Active")
|
||||
assert_that(stale).is_not_equal(active)
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UIStrings: knowledge_panel keys (D-042)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void:
|
||||
## Journal title uses knowledge_panel.tab_contacts.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_empty_state_exists() -> void:
|
||||
## Empty state message uses knowledge_panel.empty_state.
|
||||
assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message(
|
||||
"UIStrings must have 'knowledge_panel.empty_state' key (D-042)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.empty_state"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true()
|
||||
|
||||
|
||||
func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void:
|
||||
if not UIStrings.has_key("knowledge_panel.tab_contacts"): return
|
||||
assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25)
|
||||
## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict.
|
||||
## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*").
|
||||
## Regression guard: verify the dicts are gone and UIStrings fallback works.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void:
|
||||
## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||
## If this test fails, the hardcoded dict was accidentally re-introduced.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message(
|
||||
"D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_d042_fixed_panel_has_no_source_labels_dict() -> void:
|
||||
## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message(
|
||||
"D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd"
|
||||
).is_true()
|
||||
panel.queue_free()
|
||||
|
||||
|
||||
func test_d042_uistrings_fallback_for_unknown_confidence() -> void:
|
||||
## UIStrings falls back to the key string itself for missing keys.
|
||||
## journal_panel.gd relies on this for graceful degradation.
|
||||
var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level")
|
||||
assert_that(fallback).override_failure_message(
|
||||
"UIStrings fallback must return the key string itself for unknown keys"
|
||||
).is_equal("knowledge_panel.confidence_nonexistent_level")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_canvas_insert_constant_is_10() -> void:
|
||||
assert_int(Constants.CANVAS_INSERT).is_equal(10)
|
||||
|
||||
|
||||
func test_journal_fade_constants_reasonable() -> void:
|
||||
## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI.
|
||||
var panel := _make_journal_panel()
|
||||
if panel == null: return
|
||||
assert_float(panel.FADE_IN).is_between(0.0, 0.5)
|
||||
assert_float(panel.FADE_OUT).is_between(0.0, 0.5)
|
||||
panel.queue_free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://cao5jf5h36img
|
||||
@@ -95,7 +95,7 @@ func test_frame_encode_large_payload_length() -> void:
|
||||
|
||||
func test_framed_protocol_snapshot_roundtrip() -> void:
|
||||
# Encode a snapshot with Protocol, frame it, decode the frame, decode the snapshot
|
||||
var snapshot_data := {"tick": 42, "version": Protocol.PROTOCOL_VERSION, "entities": []}
|
||||
var snapshot_data := {"tick": 42, "version": 23, "entities": []}
|
||||
var encoded: Variant = Messagepack.encode(snapshot_data)
|
||||
assert_that(encoded.status).is_null()
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
## Sprint 37 — Scene-level merge-path UI flow tests (#873)
|
||||
##
|
||||
## Four flows that cover the critical paths through the pre-game UI.
|
||||
## These tests are the merge-gate mechanism added in the Sprint 36 retro:
|
||||
## regressions like #872 (New Game hang) must be caught here, not in post-merge
|
||||
## smoke tests.
|
||||
##
|
||||
## Pattern: load scene → simulate input via button.pressed.emit() or direct
|
||||
## handler call → assert terminal state. No pixel diffing, no xdotool.
|
||||
##
|
||||
## NOTE: Tests that end in a scene transition (change_scene_to_file) assert state
|
||||
## synchronously before the deferred transition fires. The test scene is
|
||||
## queue_freed in after_test() regardless.
|
||||
##
|
||||
## Reference: test_character_creation_sprint28.gd
|
||||
## Ticket: #873 | motivating regression: #872
|
||||
class_name TestMergePathFlowsSprint37
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_MENU_SCENE_PATH := "res://scenes/main_menu.tscn"
|
||||
const CHAR_CREATE_SCENE_PATH := "res://scenes/character_creation.tscn"
|
||||
|
||||
var _scene = null # MainMenu or CharacterCreation — untyped, varies per test
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
# Reset all shared state that these flows touch
|
||||
SimBridge.disconnect_from_sim()
|
||||
SimBridge._last_snapshot = null
|
||||
SimBridge._outbound_buffer.clear()
|
||||
GameState.bookmark_catalog = []
|
||||
GameState.pending_load_path = ""
|
||||
# Clear MetaStack from any leftover overlays to prevent push/pop ordering issues
|
||||
MetaStack._stack.clear()
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if is_instance_valid(_scene):
|
||||
_scene.queue_free()
|
||||
_scene = null
|
||||
SimBridge.disconnect_from_sim()
|
||||
SimBridge._outbound_buffer.clear()
|
||||
MetaStack._stack.clear()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
func _load_main_menu() -> void:
|
||||
var packed := load(MAIN_MENU_SCENE_PATH) as PackedScene
|
||||
# Hard-fail on missing scene (PR #135 review T3): silent skip turns a broken
|
||||
# merge-path test into a uselessly green one.
|
||||
assert_that(packed).override_failure_message(
|
||||
"main_menu.tscn missing — merge-path coverage is broken, not skipped"
|
||||
).is_not_null()
|
||||
_scene = packed.instantiate()
|
||||
add_child(_scene)
|
||||
|
||||
|
||||
func _load_char_create() -> void:
|
||||
var packed := load(CHAR_CREATE_SCENE_PATH) as PackedScene
|
||||
# Hard-fail on missing scene (PR #135 review T3): silent skip turns a broken
|
||||
# merge-path test into a uselessly green one.
|
||||
assert_that(packed).override_failure_message(
|
||||
"character_creation.tscn missing — merge-path coverage is broken, not skipped"
|
||||
).is_not_null()
|
||||
_scene = packed.instantiate()
|
||||
# Seed required state so Start is not disabled (guard added in PR #134 / R2-Hoshe-1).
|
||||
# Individual tests override these as needed. add_child() must run first so
|
||||
# _ready() populates @onready vars (_footer_start, etc.) that
|
||||
# _update_start_btn_state() dereferences.
|
||||
add_child(_scene)
|
||||
_scene._selected_bookmark_id = "test-bookmark"
|
||||
_scene._selected_location_id = "test-location"
|
||||
if _scene.has_method("_update_start_btn_state"):
|
||||
_scene._update_start_btn_state()
|
||||
|
||||
|
||||
func _make_catalog_snapshot() -> Dictionary:
|
||||
## Minimal valid snapshot with a bookmark_catalog for flow-1 testing.
|
||||
## D-192: no version field required; decode accepts snapshots with or without.
|
||||
return {
|
||||
"tick": 0,
|
||||
"entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Morning", "tick_rate": "Full"},
|
||||
"player_facing": "North",
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"visible_tiles": [],
|
||||
"nearby_interactions": [],
|
||||
"pending_recognitions": [],
|
||||
"bookmark_catalog": {
|
||||
"bookmarks": [
|
||||
{
|
||||
"id": "bm_tycoon_arion",
|
||||
"title": "Arion Freight Broker",
|
||||
"subtitle": "Start at Arion orbital",
|
||||
"flavor": "Commodities and logistics.",
|
||||
"default_location": "arion",
|
||||
"allowed_locations": ["arion", "arion_low"],
|
||||
"allowed_locations_cultures": ["arion"],
|
||||
"career": "tycoon",
|
||||
"starting_capital_tractus": 50000,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Flow 1: main menu → new game → loading state → catalog received → resolved
|
||||
# =============================================================================
|
||||
# Regression guard for #872: New Game used to hang on "Connecting to simulation..."
|
||||
# because bookmark_catalog was overwritten in receive_bytes before poll_snapshot consumed it.
|
||||
# This test catches that regression by verifying the full state machine:
|
||||
# pressed → loading visible → catalog signal → loading dismissed.
|
||||
|
||||
func test_new_game_shows_loading_screen() -> void:
|
||||
## Pressing New Game must show the loading screen and set _waiting_for_catalog.
|
||||
_load_main_menu()
|
||||
if _scene == null:
|
||||
return
|
||||
|
||||
assert_bool(_scene._waiting_for_catalog).override_failure_message(
|
||||
"_waiting_for_catalog must be false before New Game is pressed"
|
||||
).is_false()
|
||||
|
||||
# Press New Game via the button signal (same as real player input)
|
||||
_scene._new_game_btn.pressed.emit()
|
||||
|
||||
assert_bool(_scene._waiting_for_catalog).override_failure_message(
|
||||
"_waiting_for_catalog must be true after New Game pressed"
|
||||
).is_true()
|
||||
assert_that(_scene._loading_screen).override_failure_message(
|
||||
"Loading screen instance must exist after New Game pressed"
|
||||
).is_not_null()
|
||||
assert_bool(_scene._loading_screen.visible).override_failure_message(
|
||||
"Loading screen must be visible after New Game pressed"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_new_game_catalog_snapshot_resolves_loading_state() -> void:
|
||||
## When snapshot_received fires with a bookmark_catalog, the loading state must clear.
|
||||
## This is the exact regression introduced in #872 — if the catalog is never delivered,
|
||||
## _waiting_for_catalog stays true and the screen hangs forever.
|
||||
_load_main_menu()
|
||||
if _scene == null:
|
||||
return
|
||||
|
||||
# Simulate the New Game press to set up the signal subscription and loading state.
|
||||
# In test mode, connect_to_sim() immediately fires CONNECTED, which triggers
|
||||
# _on_sim_state_changed_for_new_game and connects snapshot_received.
|
||||
_scene._new_game_btn.pressed.emit()
|
||||
|
||||
assert_bool(_scene._waiting_for_catalog).override_failure_message(
|
||||
"Precondition: _waiting_for_catalog must be true before catalog arrives"
|
||||
).is_true()
|
||||
|
||||
# Deliver the catalog via the signal path (same path the server uses in live mode).
|
||||
# snapshot_received is emitted here directly because in test mode poll_snapshot()
|
||||
# uses the harness snapshot (no catalog). The carry-forward fix (#872) ensures
|
||||
# this signal path also works correctly in live mode when ticks batch.
|
||||
var catalog_snapshot := _make_catalog_snapshot()
|
||||
SimBridge.snapshot_received.emit(catalog_snapshot)
|
||||
|
||||
# Terminal state: loading resolved
|
||||
assert_bool(_scene._waiting_for_catalog).override_failure_message(
|
||||
"_waiting_for_catalog must be false after catalog snapshot delivered — #872 regression"
|
||||
).is_false()
|
||||
# PR #135 review H4: assert the SimBridge terminus, not just the loading flag.
|
||||
# In test mode connect_to_sim() jumps state to CONNECTED synchronously; this
|
||||
# guarantees the flow reached its terminal state, not merely that the catalog
|
||||
# flag cleared.
|
||||
assert_int(SimBridge.state).override_failure_message(
|
||||
"SimBridge must be in CONNECTED terminus after catalog resolves — flow completion guard"
|
||||
).is_equal(SimBridge.ConnectionState.CONNECTED)
|
||||
assert_bool(GameState.bookmark_catalog.size() > 0).override_failure_message(
|
||||
"GameState.bookmark_catalog must be populated after catalog snapshot applied"
|
||||
).is_true()
|
||||
assert_str(GameState.bookmark_catalog[0].get("id", "")).override_failure_message(
|
||||
"Catalog entry must have the expected bookmark id"
|
||||
).is_equal("bm_tycoon_arion")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Flow 2: main menu → load game → save picker → save selected
|
||||
# =============================================================================
|
||||
|
||||
func test_load_game_save_picker_shows_on_browse() -> void:
|
||||
## Calling _on_load_game_browse() must show the save picker panel.
|
||||
_load_main_menu()
|
||||
if _scene == null:
|
||||
return
|
||||
|
||||
assert_bool(_scene._load_panel.visible).override_failure_message(
|
||||
"Load panel must be hidden before Load Game is pressed"
|
||||
).is_false()
|
||||
|
||||
_scene._on_load_game_browse()
|
||||
|
||||
assert_bool(_scene._load_panel.visible).override_failure_message(
|
||||
"Load panel must be visible after _on_load_game_browse()"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_load_game_save_selection_sets_pending_load_path() -> void:
|
||||
## Selecting a save entry must set GameState.pending_load_path for main.gd to consume.
|
||||
_load_main_menu()
|
||||
if _scene == null:
|
||||
return
|
||||
|
||||
var mock_save := {
|
||||
"game_id": "20260421-120000-abc123",
|
||||
"newest_save": "quicksave.sav",
|
||||
}
|
||||
|
||||
# Call _on_save_selected directly — mirrors what the generated save-list button does.
|
||||
_scene._on_save_selected(mock_save)
|
||||
|
||||
assert_str(GameState.pending_load_path).override_failure_message(
|
||||
"pending_load_path must be set to the selected save's full path"
|
||||
).is_equal("user://saves/20260421-120000-abc123/quicksave.sav")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Flow 3: character creation → submit → sim_bridge receives correct payload
|
||||
# =============================================================================
|
||||
|
||||
func test_character_creation_submit_sends_confirm_bookmark_action() -> void:
|
||||
## _on_start() must queue a ConfirmBookmark action with the selected bookmark
|
||||
## and location IDs. This is the payload the server uses to initialize the run.
|
||||
_load_char_create()
|
||||
if _scene == null:
|
||||
return
|
||||
|
||||
# Ensure SimBridge is connected so send_named_action doesn't silently drop the action
|
||||
SimBridge.connect_to_sim() # test mode: immediately CONNECTED
|
||||
|
||||
_scene._selected_bookmark_id = "bm_tycoon_arion"
|
||||
_scene._selected_location_id = "arion"
|
||||
if _scene.has_method("_update_start_btn_state"):
|
||||
_scene._update_start_btn_state()
|
||||
|
||||
SimBridge._outbound_buffer.clear()
|
||||
_scene._on_start()
|
||||
|
||||
var found := false
|
||||
for entry in SimBridge._outbound_buffer:
|
||||
if entry.get("action_name") == "ConfirmBookmark":
|
||||
var data: Variant = entry.get("action_data")
|
||||
if (
|
||||
data is Dictionary
|
||||
and data.get("bookmark_id") == "bm_tycoon_arion"
|
||||
and data.get("starting_location_id") == "arion"
|
||||
):
|
||||
found = true
|
||||
break
|
||||
assert_bool(found).override_failure_message(
|
||||
"_outbound_buffer must contain ConfirmBookmark{bookmark_id='bm_tycoon_arion', starting_location_id='arion'}"
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Flow 4: bookmark tab → select location → confirm → server gets bookmark action
|
||||
# =============================================================================
|
||||
|
||||
func test_bookmark_tab_select_location_then_confirm_queues_action() -> void:
|
||||
## Exercises the full selection path: picking a bookmark, picking a location, then
|
||||
## confirming. Verifies the correct bookmark_id + starting_location_id reach the server.
|
||||
## This is the flow the player actually takes — selection handlers must propagate
|
||||
## to the outbound buffer correctly.
|
||||
|
||||
# Populate catalog before scene instantiation so _build_bookmark_cards() sees it
|
||||
GameState.bookmark_catalog = [
|
||||
{
|
||||
"id": "bm_tycoon_arion",
|
||||
"title": "Arion Freight Broker",
|
||||
"subtitle": "Start at Arion orbital",
|
||||
"flavor": "Commodities and logistics.",
|
||||
"default_location": "arion",
|
||||
"allowed_locations": ["arion", "arion_low"],
|
||||
"allowed_locations_cultures": ["arion"],
|
||||
"career": "tycoon",
|
||||
"starting_capital_tractus": 50000,
|
||||
}
|
||||
]
|
||||
|
||||
_load_char_create()
|
||||
if _scene == null:
|
||||
return
|
||||
|
||||
SimBridge.connect_to_sim() # test mode: immediately CONNECTED
|
||||
SimBridge._outbound_buffer.clear()
|
||||
|
||||
# Simulate the player selecting the bookmark card
|
||||
var bm: Dictionary = GameState.bookmark_catalog[0]
|
||||
_scene._on_bookmark_selected(bm)
|
||||
|
||||
assert_str(_scene._selected_bookmark_id).override_failure_message(
|
||||
"_on_bookmark_selected must update _selected_bookmark_id"
|
||||
).is_equal("bm_tycoon_arion")
|
||||
assert_str(_scene._selected_location_id).override_failure_message(
|
||||
"_on_bookmark_selected must populate _selected_location_id from default_location"
|
||||
).is_not_empty()
|
||||
|
||||
# Simulate the player picking a specific allowed location
|
||||
_scene._on_location_selected("arion_low")
|
||||
|
||||
assert_str(_scene._selected_location_id).override_failure_message(
|
||||
"_on_location_selected must update _selected_location_id"
|
||||
).is_equal("arion_low")
|
||||
|
||||
# Confirm — sends the action to the server
|
||||
_scene._on_start()
|
||||
|
||||
var found := false
|
||||
for entry in SimBridge._outbound_buffer:
|
||||
if entry.get("action_name") == "ConfirmBookmark":
|
||||
var data: Variant = entry.get("action_data")
|
||||
if (
|
||||
data is Dictionary
|
||||
and data.get("bookmark_id") == "bm_tycoon_arion"
|
||||
and data.get("starting_location_id") == "arion_low"
|
||||
):
|
||||
found = true
|
||||
break
|
||||
assert_bool(found).override_failure_message(
|
||||
"_outbound_buffer must contain ConfirmBookmark{bookmark_id='bm_tycoon_arion', starting_location_id='arion_low'}"
|
||||
).is_true()
|
||||
@@ -1,321 +0,0 @@
|
||||
## Sprint 18 — Minimap rendering (#151)
|
||||
## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered),
|
||||
## D-049 (z-layer 6 = InsertOverlay)
|
||||
##
|
||||
## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs.
|
||||
## Scene: res://ui/minimap.tscn (class_name MinimapRenderer)
|
||||
## Positioned at InsertOverlay/Minimap in main.tscn.
|
||||
##
|
||||
## Tests run against live Stig implementation (minimap.gd).
|
||||
class_name TestMinimapSprint18
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn"
|
||||
const MAIN_SCENE = preload("res://scenes/main.tscn")
|
||||
|
||||
func _make_minimap() -> Control:
|
||||
if not ResourceLoader.exists(MINIMAP_SCENE_PATH):
|
||||
push_warning("TestMinimapSprint18: minimap.tscn not found — skip")
|
||||
return null
|
||||
var node: Control = load(MINIMAP_SCENE_PATH).instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _make_poi(overrides: Dictionary = {}) -> Dictionary:
|
||||
var base: Dictionary = {
|
||||
"id": "poi_test_001",
|
||||
"x": 20,
|
||||
"y": 15,
|
||||
"poi_category": "location",
|
||||
"label": "Exit A",
|
||||
}
|
||||
base.merge(overrides, true)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.discovered_pois = []
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.insert_active = true
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.discovered_pois = []
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.insert_active = true
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scene and class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message(
|
||||
"Minimap scene must exist at res://ui/minimap.tscn (#151)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_minimap_instantiates_without_crash() -> void:
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
assert_that(mm).is_not_null()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
func test_minimap_is_minimap_renderer_class() -> void:
|
||||
## class_name MinimapRenderer in minimap.gd.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
assert_bool(mm is MinimapRenderer).override_failure_message(
|
||||
"Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)"
|
||||
).is_true()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants: D-015, visual parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_radius_constant() -> void:
|
||||
## MINIMAP_RADIUS defines the sim-tile distance of visible POI area.
|
||||
## Value is tuned to 24 tiles — reasonable coverage without map reveal.
|
||||
assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message(
|
||||
"MinimapRenderer.MINIMAP_RADIUS must be 24.0"
|
||||
).is_equal_approx(24.0, 0.01)
|
||||
|
||||
|
||||
func test_player_dot_radius_defined() -> void:
|
||||
## Player dot must be visible (> 0) and distinct from POI dot.
|
||||
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0)
|
||||
|
||||
|
||||
func test_poi_dot_radius_defined() -> void:
|
||||
## POI dot must be visible (> 0).
|
||||
assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0)
|
||||
|
||||
|
||||
func test_player_dot_larger_than_poi_dot() -> void:
|
||||
## D-015: Player is always centered and visually distinct.
|
||||
## Player dot should be at least as large as POI dot.
|
||||
assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _category_color() — D-013 POI category color mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_category_color_danger_is_hostile_color() -> void:
|
||||
## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red)
|
||||
for cat in ["danger", "threat", "hostile"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_HOSTILE)
|
||||
|
||||
|
||||
func test_category_color_evidence_is_poi_color() -> void:
|
||||
## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber)
|
||||
for cat in ["evidence", "note", "clue"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_POI)
|
||||
|
||||
|
||||
func test_category_color_contact_is_unknown_color() -> void:
|
||||
## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal)
|
||||
for cat in ["contact", "npc", "person"]:
|
||||
var color: Color = MinimapRenderer._category_color(cat)
|
||||
assert_that(color).override_failure_message(
|
||||
"Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat
|
||||
).is_equal(Constants.ENTITY_COLOR_UNKNOWN)
|
||||
|
||||
|
||||
func test_category_color_unknown_category_defaults_to_insert_text() -> void:
|
||||
## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default)
|
||||
var color: Color = MinimapRenderer._category_color("some_unknown_type")
|
||||
assert_that(color).override_failure_message(
|
||||
"Unknown category must default to INSERT_COLOR_TEXT"
|
||||
).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
|
||||
func test_category_color_empty_string_defaults() -> void:
|
||||
## Empty category string → default color, no crash.
|
||||
var color: Color = MinimapRenderer._category_color("")
|
||||
assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT)
|
||||
|
||||
|
||||
func test_category_color_case_insensitive() -> void:
|
||||
## Category matching is case-insensitive (uses to_lower()).
|
||||
var danger_lower := MinimapRenderer._category_color("danger")
|
||||
var danger_upper := MinimapRenderer._category_color("DANGER")
|
||||
var danger_mixed := MinimapRenderer._category_color("Danger")
|
||||
assert_that(danger_lower).is_equal(danger_upper)
|
||||
assert_that(danger_lower).is_equal(danger_mixed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_insert_active() — D-049: insert layer visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_set_insert_active_false_hides_minimap() -> void:
|
||||
## When insert is inactive, minimap must be hidden.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
mm.set_insert_active(false)
|
||||
assert_bool(mm.visible).override_failure_message(
|
||||
"set_insert_active(false) must hide the minimap"
|
||||
).is_false()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
func test_set_insert_active_true_shows_minimap() -> void:
|
||||
## When insert is active, minimap must be visible.
|
||||
var mm := _make_minimap()
|
||||
if mm == null: return
|
||||
mm.set_insert_active(false)
|
||||
mm.set_insert_active(true)
|
||||
assert_bool(mm.visible).override_failure_message(
|
||||
"set_insert_active(true) must show the minimap"
|
||||
).is_true()
|
||||
mm.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main scene structural check: InsertOverlay/Minimap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_minimap_in_main_scene_on_insert_overlay() -> void:
|
||||
## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer.
|
||||
## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap.
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped")
|
||||
return
|
||||
var scene: Node = MAIN_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
# Check for Minimap in InsertOverlay
|
||||
var insert_overlay := scene.get_node_or_null("InsertOverlay")
|
||||
assert_that(insert_overlay != null).override_failure_message(
|
||||
"InsertOverlay (CanvasLayer 10) must exist in main.tscn"
|
||||
).is_true()
|
||||
if insert_overlay == null: return
|
||||
|
||||
var minimap := insert_overlay.get_node_or_null("Minimap")
|
||||
assert_that(minimap != null).override_failure_message(
|
||||
"Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)"
|
||||
).is_true()
|
||||
if minimap == null: return
|
||||
|
||||
assert_bool(minimap is MinimapRenderer).override_failure_message(
|
||||
"InsertOverlay/Minimap must be a MinimapRenderer instance"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_insert_overlay_is_canvas_layer_10() -> void:
|
||||
## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049).
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped")
|
||||
return
|
||||
var scene: Node = MAIN_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer
|
||||
if insert_overlay == null: return
|
||||
assert_int(insert_overlay.layer).override_failure_message(
|
||||
"InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT
|
||||
).is_equal(Constants.CANVAS_INSERT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.discovered_pois integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_discovered_pois_field_exists_in_gamestate() -> void:
|
||||
assert_bool(GameState.has("discovered_pois")).override_failure_message(
|
||||
"GameState must have 'discovered_pois' field (#151)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_discovered_pois_set_from_poi_list_snapshot() -> void:
|
||||
## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [
|
||||
_make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}),
|
||||
_make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}),
|
||||
],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||
"discovered_pois must be populated from snapshot 'poi_list' field"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
func test_discovered_pois_set_from_discovered_pois_snapshot() -> void:
|
||||
## Snapshot with "discovered_pois" key also works.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"discovered_pois": [_make_poi()],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_discovered_pois_persists_when_absent_from_snapshot() -> void:
|
||||
## Like player_knowledge: POI list persists when server doesn't send an update.
|
||||
GameState.discovered_pois = [_make_poi()]
|
||||
GameState.apply_snapshot({"tick": 3})
|
||||
assert_int(GameState.discovered_pois.size()).override_failure_message(
|
||||
"discovered_pois must persist when absent from snapshot (not cleared each tick)"
|
||||
).is_equal(1)
|
||||
|
||||
|
||||
func test_discovered_pois_poi_category_field_present() -> void:
|
||||
## MinimapRenderer reads poi_category to determine shape/color.
|
||||
## Verify the wire format includes this field.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [_make_poi({"poi_category": "danger"})],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||
assert_bool(first_poi.has("poi_category")).override_failure_message(
|
||||
"POI entries must have 'poi_category' field for MinimapRenderer shape selection"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_discovered_pois_x_y_fields_present() -> void:
|
||||
## MinimapRenderer reads x, y for position calculation.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"poi_list": [_make_poi({"x": 42, "y": 17})],
|
||||
})
|
||||
assert_int(GameState.discovered_pois.size()).is_greater(0)
|
||||
var first_poi: Dictionary = GameState.discovered_pois[0]
|
||||
assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message(
|
||||
"POI entries must have 'x' and 'y' coordinate fields"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Color constants: all distinct
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_category_colors_are_distinct() -> void:
|
||||
## All three primary category color groups must be visually distinct.
|
||||
var danger_color := MinimapRenderer._category_color("danger")
|
||||
var evidence_color := MinimapRenderer._category_color("evidence")
|
||||
var contact_color := MinimapRenderer._category_color("contact")
|
||||
assert_that(danger_color).is_not_equal(evidence_color)
|
||||
assert_that(evidence_color).is_not_equal(contact_color)
|
||||
assert_that(danger_color).is_not_equal(contact_color)
|
||||
@@ -1 +0,0 @@
|
||||
uid://dm46ip672i3jc
|
||||
@@ -4,7 +4,6 @@
|
||||
##
|
||||
## API per Tyre architecture review:
|
||||
## show_monologue(text, duration, priority=2, is_urgent=false)
|
||||
## GameState.lattice_profile selects colour palette
|
||||
class_name TestMonologueDisplay
|
||||
extends GdUnitTestSuite
|
||||
|
||||
@@ -35,14 +34,11 @@ func _label_text(d: Node) -> String:
|
||||
|
||||
func before_test() -> void:
|
||||
## Reset GameState fields touched by this suite so tests don't bleed into each other.
|
||||
## lattice_profile: tests that care about colour set it explicitly — default to baseline.
|
||||
## current_monologue: GameState integration tests need null as start state.
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -310,34 +306,10 @@ func test_text_has_color_bbcode() -> void:
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lattice colour palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_augmented_colour_differs_from_baseline() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_augmented"
|
||||
d.show_monologue("Detective.", 5.0)
|
||||
var aug_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
d._next_fade_in_msec = 0.0
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Smuggler.", 5.0)
|
||||
var base_txt := _label_text(d)
|
||||
|
||||
assert_that(aug_txt).is_not_equal(base_txt)
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_urgent_colour_differs_from_standard() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Normal.", 5.0, 2, false)
|
||||
var std_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
@@ -350,17 +322,6 @@ func test_urgent_colour_differs_from_standard() -> void:
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_unknown_profile_falls_back_without_crash() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
GameState.lattice_profile = "lattice_hypothetical_tier_x"
|
||||
d.show_monologue("Future proof.", 5.0)
|
||||
var txt := _label_text(d)
|
||||
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
## Client P0 regression tests: guards for Bug #5 (monologue lost) and Bug #2 (camera drift).
|
||||
## Client P0 regression tests: guards for Bug #5 (monologue lost), Bug #2 (camera drift),
|
||||
## and Bug #872 (bookmark_catalog lost on batch receive).
|
||||
## These must pass before any other client testing is meaningful.
|
||||
##
|
||||
## Bug #5: Monologue text lost when server sends snapshots faster than client
|
||||
## consumes them. Fix: carry-forward one-shot events in receive_bytes().
|
||||
## Bug #2: Camera doesn't center at startup / drifts during pause. Fix: anchor
|
||||
## pattern with smoothing disabled until first snapshot applied.
|
||||
## Bug #872: bookmark_catalog silently dropped when tick 0 (with catalog) and tick 1
|
||||
## (without catalog) arrive in the same TCP batch. Fix: carry-forward
|
||||
## bookmark_catalog in receive_bytes() like save_result/settings_response.
|
||||
##
|
||||
## Spec ref: stig-round3.md Section 1 (P0 tests #1, #2).
|
||||
class_name TestP0Regressions
|
||||
@@ -39,12 +43,13 @@ func after_test() -> void:
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
## Encode a minimal valid snapshot as MessagePack bytes.
|
||||
## Protocol.decode_snapshot() requires: tick, version, entities (with kind as
|
||||
## bare string for unit enum variants per rmp_serde wire format).
|
||||
## Protocol.decode_snapshot() requires: tick, entities (with kind as bare string
|
||||
## for unit enum variants per rmp_serde wire format). D-192 dropped the version
|
||||
## field — kept here inertly in existing fixtures so decode still accepts either
|
||||
## shape while older tests migrate.
|
||||
func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
|
||||
var snapshot := {
|
||||
"tick": overrides.get("tick", 1),
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"entities": overrides.get("entities", [{
|
||||
"entity_id": 1,
|
||||
"x": 10.0,
|
||||
@@ -71,6 +76,8 @@ func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
|
||||
snapshot["current_monologue"] = overrides["current_monologue"]
|
||||
if overrides.has("current_dialogue"):
|
||||
snapshot["current_dialogue"] = overrides["current_dialogue"]
|
||||
if overrides.has("bookmark_catalog"):
|
||||
snapshot["bookmark_catalog"] = overrides["bookmark_catalog"]
|
||||
var result = Messagepack.encode(snapshot)
|
||||
return result.value
|
||||
|
||||
@@ -229,3 +236,71 @@ func test_camera_anchored_after_pause_unpause() -> void:
|
||||
# Camera still tracking player (position may have changed due to test_snapshot)
|
||||
var player_pos := GameState.player_position * Constants.TILE_SIZE
|
||||
assert_that(camera.global_position).is_equal(player_pos)
|
||||
|
||||
|
||||
# -- Bug #872: bookmark_catalog carry-forward ---------------------------------
|
||||
# Server sends bookmark_catalog on tick 0. If tick 1 arrives before poll_snapshot()
|
||||
# is called (same TCP batch), the inner receive loop overwrites _last_snapshot and
|
||||
# the catalog is silently lost. The carry-forward fix must preserve the catalog.
|
||||
|
||||
func test_bookmark_catalog_not_lost_on_snapshot_overwrite() -> void:
|
||||
var catalog := {
|
||||
"bookmarks": [
|
||||
{
|
||||
"id": "bm_tycoon_arion",
|
||||
"title": "Arion Freight Broker",
|
||||
"subtitle": "Start at Arion orbital",
|
||||
"flavor": "Commodities and logistics.",
|
||||
"default_location": "arion",
|
||||
"allowed_locations": ["arion"],
|
||||
"allowed_locations_cultures": ["arion"],
|
||||
"career": "tycoon",
|
||||
"starting_capital_tractus": 50000,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Tick 0: server sends catalog automatically after handshake
|
||||
var bytes_tick0 := _make_snapshot_bytes({
|
||||
"tick": 0,
|
||||
"bookmark_catalog": catalog,
|
||||
})
|
||||
SimBridge.receive_bytes(bytes_tick0)
|
||||
|
||||
# Tick 1: server sends next tick WITHOUT catalog (fast server, same TCP batch)
|
||||
var bytes_tick1 := _make_snapshot_bytes({"tick": 1})
|
||||
SimBridge.receive_bytes(bytes_tick1)
|
||||
|
||||
# Assert: catalog must survive the overwrite — this is the Bug #872 fix.
|
||||
assert_that(SimBridge._last_snapshot).is_not_null()
|
||||
var bmc: Variant = SimBridge._last_snapshot.get("bookmark_catalog")
|
||||
assert_that(bmc).is_not_null()
|
||||
assert_that(bmc is Dictionary).is_true()
|
||||
var bookmarks: Variant = bmc.get("bookmarks")
|
||||
assert_that(bookmarks is Array).is_true()
|
||||
assert_that((bookmarks as Array).size()).is_equal(1)
|
||||
assert_that((bookmarks as Array)[0].get("id")).is_equal("bm_tycoon_arion")
|
||||
|
||||
|
||||
func test_bookmark_catalog_not_carried_forward_after_consumption() -> void:
|
||||
# After poll_snapshot() consumes the catalog, the next snapshot without catalog
|
||||
# must NOT carry it forward (it was already consumed and the scene transitioned).
|
||||
var catalog := {
|
||||
"bookmarks": [{"id": "bm_test", "title": "Test", "subtitle": "", "flavor": "",
|
||||
"default_location": "arion", "allowed_locations": [], "allowed_locations_cultures": [],
|
||||
"career": "tycoon", "starting_capital_tractus": 0}]
|
||||
}
|
||||
var bytes_tick0 := _make_snapshot_bytes({"tick": 0, "bookmark_catalog": catalog})
|
||||
SimBridge.receive_bytes(bytes_tick0)
|
||||
|
||||
# Simulate poll_snapshot() consumption — sets _last_snapshot to null
|
||||
var snapshot = SimBridge._last_snapshot
|
||||
SimBridge._last_snapshot = null
|
||||
assert_that(snapshot).is_not_null()
|
||||
|
||||
# Next snapshot arrives without catalog — no carry-forward should happen
|
||||
var bytes_tick1 := _make_snapshot_bytes({"tick": 1})
|
||||
SimBridge.receive_bytes(bytes_tick1)
|
||||
|
||||
var bmc: Variant = SimBridge._last_snapshot.get("bookmark_catalog")
|
||||
assert_that(bmc).is_null()
|
||||
|
||||
@@ -188,7 +188,7 @@ func test_decode_snapshot_malformed_entities_counted() -> void:
|
||||
# Snapshot with one valid and one malformed entity — decode_errors should count the bad one
|
||||
var raw := {
|
||||
"tick": 7,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 10.0, "z": 0, "kind": "Npc"},
|
||||
{"entity_id": 2, "broken": true}, # Missing required fields
|
||||
@@ -282,7 +282,6 @@ func test_decode_snapshot_v2_full() -> void:
|
||||
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.tick).is_equal(500)
|
||||
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
|
||||
# game_time
|
||||
assert_that(snapshot.game_time).is_not_null()
|
||||
@@ -309,7 +308,6 @@ func test_existing_fixtures_have_v2_fields() -> void:
|
||||
var bytes = _load_fixture(fixture_name)
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
assert_that(snapshot.player_facing).is_equal("North")
|
||||
assert_that(snapshot.game_time).is_not_null()
|
||||
|
||||
@@ -324,30 +322,6 @@ func test_multi_entity_visibility_sectors() -> void:
|
||||
assert_that(snapshot.entities[3].visibility).is_equal("Forward")
|
||||
|
||||
|
||||
# -- Version enforcement (strict PROTOCOL_VERSION check) --------------------
|
||||
|
||||
func test_decode_snapshot_rejects_missing_version() -> void:
|
||||
# Snapshot without version field → rejected by strict version check
|
||||
var v1_raw := {"tick": 10, "entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player"},
|
||||
]}
|
||||
var encoded: Variant = Messagepack.encode(v1_raw)
|
||||
assert_that(encoded.status).is_null()
|
||||
|
||||
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
|
||||
assert_that(snapshot).is_null()
|
||||
|
||||
|
||||
func test_decode_snapshot_rejects_old_version() -> void:
|
||||
# Snapshot with version 2 → rejected by strict version check
|
||||
var old_raw := {"tick": 10, "version": 2, "entities": []}
|
||||
var encoded: Variant = Messagepack.encode(old_raw)
|
||||
assert_that(encoded.status).is_null()
|
||||
|
||||
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
|
||||
assert_that(snapshot).is_null()
|
||||
|
||||
|
||||
# -- Batch input fixture (D-030 Layer 1 bidirectional symmetry) ----------------
|
||||
|
||||
func test_decode_batch_input_fixture() -> void:
|
||||
@@ -391,7 +365,7 @@ func test_decode_snapshot_with_bookmark_catalog() -> void:
|
||||
# Hand-built dict — fixture generation requires server work, skip round-trip (#614).
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"bookmark_catalog": {
|
||||
"bookmarks": [
|
||||
@@ -451,7 +425,7 @@ func test_decode_snapshot_no_bookmark_catalog_is_null() -> void:
|
||||
# Snapshot without bookmark_catalog key → field should be null.
|
||||
var raw := {
|
||||
"tick": 2,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
}
|
||||
var encoded: Variant = Messagepack.encode(raw)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
## D-030 Layer 1: Protocol bridge tests for ObserverSnapshot features.
|
||||
## Validates player_stance (D-053) and player_inventory (D-065) decode,
|
||||
## GameState storage, SimBridge test mode, input encoding for stance toggles,
|
||||
## protocol version checks, and fixture round-trips.
|
||||
## Spec refs: D-053, D-065, D-020, #449
|
||||
## and fixture round-trips. Protocol version checks removed per D-192.
|
||||
## Spec refs: D-053, D-065, D-020, #449, D-192
|
||||
class_name TestProtocolBridge
|
||||
extends GdUnitTestSuite
|
||||
|
||||
@@ -24,33 +24,12 @@ func _load_fixture(name: String) -> PackedByteArray:
|
||||
return file.get_buffer(file.get_length())
|
||||
|
||||
|
||||
# -- Protocol version upgrade -------------------------------------------------
|
||||
# Tautological "PROTOCOL_VERSION == N" assertions deleted: they assert a constant
|
||||
# equals its own literal, fail mechanically on every protocol bump, and have
|
||||
# never caught a real bug. Mismatch handling is exercised by test_rejects_version_6
|
||||
# below; field-presence is exercised by the per-version decode tests.
|
||||
|
||||
func test_fixtures_at_protocol_version_8() -> void:
|
||||
# NOTE: These binary fixtures embed version 8 and are rejected by the version
|
||||
# mismatch guard in decode_snapshot(). This test is pre-existing broken since v9+.
|
||||
# Fixtures need regeneration via `make fixtures-gauntlet` to match current protocol.
|
||||
# Skipping rather than deleting to preserve the fixture round-trip pattern.
|
||||
pass
|
||||
|
||||
|
||||
func test_rejects_version_6() -> void:
|
||||
var raw := {"tick": 1, "version": 6, "entities": []}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
var snapshot = Protocol.decode_snapshot(encoded.value)
|
||||
assert_that(snapshot).is_null()
|
||||
|
||||
|
||||
# -- player_stance decode (D-053) ---------------------------------------------
|
||||
|
||||
func test_decode_player_stance_walk() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
@@ -64,7 +43,7 @@ func test_decode_player_stance_walk() -> void:
|
||||
func test_decode_player_stance_sprint() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"player_stance": "Sprint",
|
||||
"player_inventory": [],
|
||||
@@ -77,7 +56,7 @@ func test_decode_player_stance_sprint() -> void:
|
||||
func test_decode_player_stance_careful() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"player_stance": "Careful",
|
||||
"player_inventory": [],
|
||||
@@ -90,7 +69,7 @@ func test_decode_player_stance_careful() -> void:
|
||||
func test_decode_player_stance_crouch() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"player_stance": "Crouch",
|
||||
"player_inventory": [],
|
||||
@@ -104,7 +83,7 @@ func test_decode_player_stance_missing_defaults_to_walk() -> void:
|
||||
# v6 snapshot without player_stance → should default to "Walk"
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
@@ -118,7 +97,7 @@ func test_decode_player_stance_missing_defaults_to_walk() -> void:
|
||||
func test_decode_empty_inventory() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
@@ -132,7 +111,7 @@ func test_decode_smuggler_inventory_3_items() -> void:
|
||||
# D-065: smuggler carries 3 specific items
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [
|
||||
@@ -161,7 +140,7 @@ func test_decode_full_9_slot_inventory() -> void:
|
||||
items.append({"item_id": 100 + i, "name": "Item %d" % i, "slot": i})
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"player_inventory": items,
|
||||
}
|
||||
@@ -177,7 +156,7 @@ func test_decode_full_9_slot_inventory() -> void:
|
||||
func test_decode_inventory_missing_defaults_to_empty() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
@@ -188,7 +167,7 @@ func test_decode_inventory_missing_defaults_to_empty() -> void:
|
||||
func test_decode_inventory_skips_malformed_items() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"player_inventory": [
|
||||
{"item_id": 100, "name": "Valid Item", "slot": 0},
|
||||
@@ -209,7 +188,7 @@ func test_decode_inventory_skips_malformed_items() -> void:
|
||||
func test_decode_inventory_item_slot_defaults_to_zero() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"player_inventory": [
|
||||
{"item_id": 100, "name": "No Slot"},
|
||||
@@ -281,12 +260,6 @@ func test_sim_bridge_test_snapshot_has_player_inventory() -> void:
|
||||
assert_that(snap.player_inventory is Array).is_true()
|
||||
|
||||
|
||||
func test_sim_bridge_test_snapshot_uses_current_protocol_version() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
|
||||
|
||||
# -- Fixture: v6 snapshots include new fields ----------------------------------
|
||||
|
||||
func test_fixture_snapshots_have_v6_defaults() -> void:
|
||||
@@ -334,7 +307,7 @@ func test_full_v6_snapshot_decode() -> void:
|
||||
# Simulate a realistic v6 snapshot with all fields populated
|
||||
var raw := {
|
||||
"tick": 100,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"game_time": {"day": 1, "time_of_day": 720, "day_phase": "Evening", "tick_rate": "Full"},
|
||||
"player_facing": "Southeast",
|
||||
"player_stance": "Careful",
|
||||
@@ -367,7 +340,6 @@ func test_full_v6_snapshot_decode() -> void:
|
||||
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.tick).is_equal(100)
|
||||
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
assert_that(snapshot.player_facing).is_equal("Southeast")
|
||||
assert_that(snapshot.player_stance).is_equal("Careful")
|
||||
assert_that(snapshot.player_inventory.size()).is_equal(3)
|
||||
|
||||
@@ -12,7 +12,7 @@ extends GdUnitTestSuite
|
||||
func test_decode_pending_recognitions_basic() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 100, "x": 13.5, "y": 12.5, "z": 0, "remaining_ticks": 4, "total_delay_ticks": 6},
|
||||
@@ -33,7 +33,7 @@ func test_decode_pending_recognitions_basic() -> void:
|
||||
func test_decode_pending_recognitions_empty() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"pending_recognitions": [],
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func test_decode_pending_recognitions_empty() -> void:
|
||||
func test_decode_pending_recognitions_missing_defaults_empty() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
@@ -56,7 +56,7 @@ func test_decode_pending_recognitions_missing_defaults_empty() -> void:
|
||||
func test_decode_pending_recognitions_skips_malformed() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 100, "x": 13.5, "y": 12.5, "z": 0, "remaining_ticks": 4, "total_delay_ticks": 6},
|
||||
@@ -76,7 +76,7 @@ func test_decode_pending_recognitions_defaults() -> void:
|
||||
# remaining_ticks and total_delay_ticks default to 0 and 1
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 100, "x": 5.0, "y": 5.0},
|
||||
@@ -94,7 +94,7 @@ func test_decode_pending_recognitions_defaults() -> void:
|
||||
func test_decode_current_dialogue() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"current_dialogue": {
|
||||
"npc_name": "Kael",
|
||||
@@ -123,7 +123,7 @@ func test_decode_current_dialogue() -> void:
|
||||
func test_decode_current_dialogue_missing_is_null() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
@@ -136,7 +136,7 @@ func test_decode_current_dialogue_options_default_fields() -> void:
|
||||
# response_id and priority default when absent
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"current_dialogue": {
|
||||
"speech": "Just speech.",
|
||||
@@ -158,7 +158,7 @@ func test_decode_current_dialogue_options_default_fields() -> void:
|
||||
func test_decode_current_dialogue_confrontation_option() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"current_dialogue": {
|
||||
"npc_name": "Sera",
|
||||
@@ -179,7 +179,7 @@ func test_decode_current_dialogue_confrontation_option() -> void:
|
||||
func test_decode_current_dialogue_skips_malformed_options() -> void:
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"current_dialogue": {
|
||||
"npc_name": "Sera",
|
||||
@@ -332,7 +332,7 @@ func test_insert_color_constants_exist() -> void:
|
||||
func test_full_v7_snapshot_decode() -> void:
|
||||
var raw := {
|
||||
"tick": 200,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"game_time": {"day": 2, "time_of_day": 1000, "day_phase": "Evening", "tick_rate": "Full"},
|
||||
"player_facing": "West",
|
||||
"player_stance": "Careful",
|
||||
@@ -362,7 +362,6 @@ func test_full_v7_snapshot_decode() -> void:
|
||||
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.tick).is_equal(200)
|
||||
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
assert_that(snapshot.player_facing).is_equal("West")
|
||||
assert_that(snapshot.player_stance).is_equal("Careful")
|
||||
assert_that(snapshot.player_inventory.size()).is_equal(1)
|
||||
|
||||
@@ -123,23 +123,26 @@ func test_game_state_warns_on_missing_player() -> void:
|
||||
# -- SimBridge: test data completeness --
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_tiles() -> void:
|
||||
## Protocol uses "visible_tiles" (not "tiles") for test snapshot — updated from stale assertion.
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("tiles")).is_true()
|
||||
assert_that(snap.tiles.size()).is_greater(0)
|
||||
var tile = snap.tiles[0]
|
||||
assert_that(snap.has("visible_tiles")).is_true()
|
||||
assert_that(snap.visible_tiles.size()).is_greater(0)
|
||||
var tile = snap.visible_tiles[0]
|
||||
assert_that(tile.has("x")).is_true()
|
||||
assert_that(tile.has("y")).is_true()
|
||||
assert_that(tile.has("type")).is_true()
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_visible_positions() -> void:
|
||||
## Protocol uses "visible_tiles" for position data — visible_positions is derived client-side.
|
||||
## Updated from stale assertion: TestHarness snapshot never had a top-level "visible_positions".
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("visible_positions")).is_true()
|
||||
assert_that(snap.visible_positions.size()).is_greater(0)
|
||||
var pos = snap.visible_positions[0]
|
||||
assert_that(pos.has("x")).is_true()
|
||||
assert_that(pos.has("y")).is_true()
|
||||
assert_that(snap.has("visible_tiles")).is_true()
|
||||
assert_that(snap.visible_tiles.size()).is_greater(0)
|
||||
var vtile = snap.visible_tiles[0]
|
||||
assert_that(vtile.has("x")).is_true()
|
||||
assert_that(vtile.has("y")).is_true()
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_player_entity() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
@@ -162,10 +165,11 @@ func test_sim_bridge_test_snapshot_has_npc() -> void:
|
||||
assert_that(has_npc).is_true()
|
||||
|
||||
func test_sim_bridge_test_tiles_contain_all_types() -> void:
|
||||
## Protocol uses "visible_tiles" — updated from stale "tiles" assertion.
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var types: Dictionary = {}
|
||||
for tile in snap.tiles:
|
||||
for tile in snap.visible_tiles:
|
||||
types[tile.type] = true
|
||||
assert_that(types.has("floor")).is_true()
|
||||
assert_that(types.has("wall")).is_true()
|
||||
@@ -174,8 +178,6 @@ func test_sim_bridge_test_tiles_contain_all_types() -> void:
|
||||
func test_sim_bridge_test_snapshot_has_v2_fields() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("version")).is_true()
|
||||
assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
assert_that(snap.has("game_time")).is_true()
|
||||
assert_that(snap.has("player_facing")).is_true()
|
||||
assert_that(snap.has("visible_tiles")).is_true()
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
## Sprint 19 — Game session management (#258, D-085)
|
||||
## Per-game save directories: created on New Game, resumed via game-id.
|
||||
## SessionManager autoload: new_game(), resume_game(), list_game_dirs().
|
||||
class_name TestSessionManagerSprint19
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const MAIN_MENU_SCENE = preload("res://scenes/main_menu.tscn")
|
||||
|
||||
# Game IDs created during the current test — deleted in after_test().
|
||||
var _created_ids: Array = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.current_game_id = ""
|
||||
_created_ids = []
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
for game_id in _created_ids:
|
||||
var path := "user://saves/" + game_id
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
_created_ids.clear()
|
||||
GameState.current_game_id = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: call new_game() and track the created directory for cleanup.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _new_game() -> String:
|
||||
var game_id := SessionManager.new_game()
|
||||
if not game_id.is_empty():
|
||||
_created_ids.append(game_id)
|
||||
return game_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState.current_game_id field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_current_game_id_field_exists() -> void:
|
||||
## D-085: GameState must have current_game_id field.
|
||||
assert_bool(GameState.has("current_game_id")).override_failure_message(
|
||||
"GameState must have 'current_game_id' field (D-085 #258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_current_game_id_default_is_empty_string() -> void:
|
||||
## Before any session starts, current_game_id is empty.
|
||||
GameState.current_game_id = ""
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"GameState.current_game_id default must be empty string"
|
||||
).is_empty()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SessionManager autoload exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_session_manager_autoload_exists() -> void:
|
||||
## SessionManager must be registered as an autoload.
|
||||
var sm := Engine.get_singleton("SessionManager")
|
||||
assert_that(sm != null).override_failure_message(
|
||||
"SessionManager must be registered as autoload in project.godot (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_game() — game-id format and GameState update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_new_game_returns_non_empty_string() -> void:
|
||||
var game_id := _new_game()
|
||||
assert_str(game_id).override_failure_message(
|
||||
"SessionManager.new_game() must return a non-empty game-id string"
|
||||
).is_not_empty()
|
||||
|
||||
|
||||
func test_new_game_sets_current_game_id_on_gamestate() -> void:
|
||||
var game_id := _new_game()
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"new_game() must set GameState.current_game_id"
|
||||
).is_equal(game_id)
|
||||
|
||||
|
||||
func test_new_game_id_format_has_two_dashes() -> void:
|
||||
## Format: <YYYYMMDD>-<HHMMSS>-<hex6> — two separator dashes.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts.size()).override_failure_message(
|
||||
"game-id must have format <YYYYMMDD>-<HHMMSS>-<hex6> (3 parts separated by '-')"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
func test_new_game_id_first_part_is_8_digits() -> void:
|
||||
## First part is YYYYMMDD — 8 decimal digits.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[0].length()).override_failure_message(
|
||||
"game-id first part (date) must be 8 characters (YYYYMMDD)"
|
||||
).is_equal(8)
|
||||
|
||||
|
||||
func test_new_game_id_second_part_is_6_digits() -> void:
|
||||
## Second part is HHMMSS — 6 decimal digits.
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[1].length()).override_failure_message(
|
||||
"game-id second part (time) must be 6 characters (HHMMSS)"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_id_third_part_is_6_hex_chars() -> void:
|
||||
## Third part is 6 hex characters (RNG seed).
|
||||
var game_id := _new_game()
|
||||
var parts := game_id.split("-")
|
||||
assert_int(parts[2].length()).override_failure_message(
|
||||
"game-id third part (hex seed) must be 6 characters"
|
||||
).is_equal(6)
|
||||
|
||||
|
||||
func test_new_game_ids_are_unique() -> void:
|
||||
## Two rapid new_game() calls should produce different IDs
|
||||
## (different RNG seeds; same-second timestamps are valid but seeds differ).
|
||||
var id1 := _new_game()
|
||||
var id2 := _new_game()
|
||||
# Check that hex seeds differ (they almost certainly will)
|
||||
var seed1 := id1.split("-")[2]
|
||||
var seed2 := id2.split("-")[2]
|
||||
assert_str(seed1).override_failure_message(
|
||||
"Successive new_game() calls should have different RNG seeds"
|
||||
).is_not_equal(seed2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resume_game() — sets GameState.current_game_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_resume_game_sets_current_game_id() -> void:
|
||||
var test_id := "20260225-143022-a7b3f1"
|
||||
SessionManager.resume_game(test_id)
|
||||
assert_str(GameState.current_game_id).override_failure_message(
|
||||
"resume_game() must set GameState.current_game_id to the given id"
|
||||
).is_equal(test_id)
|
||||
|
||||
|
||||
func test_resume_game_overwrites_previous_game_id() -> void:
|
||||
SessionManager.resume_game("20260225-100000-aabbcc")
|
||||
SessionManager.resume_game("20260225-120000-112233")
|
||||
assert_str(GameState.current_game_id).is_equal("20260225-120000-112233")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main menu scene
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_main_menu_scene_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message(
|
||||
"Main menu scene must exist at res://scenes/main_menu.tscn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_instantiates_without_crash() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip")
|
||||
return
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
assert_that(scene).is_not_null()
|
||||
|
||||
|
||||
func test_main_menu_has_new_game_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/NewGameBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/NewGameBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_main_menu_has_continue_button() -> void:
|
||||
if not ResourceLoader.exists("res://scenes/main_menu.tscn"):
|
||||
return
|
||||
var scene: Node = MAIN_MENU_SCENE.instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
var btn := scene.get_node_or_null("VBox/ContinueBtn")
|
||||
assert_that(btn != null).override_failure_message(
|
||||
"Main menu must have VBox/ContinueBtn (#258)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project main scene changed to main_menu.tscn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_project_main_scene_is_main_menu() -> void:
|
||||
## D-085: project boots to main menu, not directly to game scene.
|
||||
var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "")
|
||||
assert_str(scene_path).override_failure_message(
|
||||
"project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)"
|
||||
).is_equal("res://scenes/main_menu.tscn")
|
||||
@@ -1 +0,0 @@
|
||||
uid://c7lnnr2apbyqw
|
||||
@@ -1,86 +1,24 @@
|
||||
## Sprint 24 — Signal acceptance tests (#588, #590, #592)
|
||||
## Sprint 24 — Signal acceptance tests (#590, #592)
|
||||
##
|
||||
## Client-side acceptance criteria:
|
||||
## - #588: character_archetype field in GameState, StartupMessage, SessionManager persistence
|
||||
## - #590: triangle_crisis_events decoded by Protocol, chimed once per triangle_id
|
||||
## - #592: news_ticker decode + update_from_state hide/show behavior
|
||||
##
|
||||
## Spec: D-032 (monologue pools per character), D-016 (client displays server data only),
|
||||
## D-042 (UI strings in yaml), D-067 (chime on recognition onset)
|
||||
## Spec: D-016 (client displays server data only), D-042 (UI strings in yaml),
|
||||
## D-067 (chime on recognition onset)
|
||||
class_name TestSignalSprint24
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn")
|
||||
|
||||
|
||||
# -- #588: Character archetype field ------------------------------------------
|
||||
|
||||
func test_game_state_has_character_archetype_field() -> void:
|
||||
assert_bool("character_archetype" in GameState).override_failure_message(
|
||||
"GameState must have a character_archetype field (#588)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_game_state_character_archetype_default_is_detective() -> void:
|
||||
# Fresh GameState defaults to "detective" (safest fallback for legacy saves).
|
||||
var archetype = GameState.get("character_archetype")
|
||||
assert_str(archetype).override_failure_message(
|
||||
"GameState.character_archetype default must be 'detective'"
|
||||
).is_equal("detective")
|
||||
|
||||
|
||||
func test_protocol_startup_message_unknown_archetype_defaults_to_detective() -> void:
|
||||
# Unknown archetype strings must not silently pass garbage to the server.
|
||||
# The match guard falls back to "Detective" and calls push_error.
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "hacker")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).is_null()
|
||||
assert_str(decoded.value["character_archetype"]).override_failure_message(
|
||||
"Unknown archetype must fall back to 'Detective'"
|
||||
).is_equal("Detective")
|
||||
|
||||
|
||||
func test_protocol_startup_message_includes_character_archetype() -> void:
|
||||
# StartupMessage wire payload must carry "character_archetype" key (#588).
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(12345, "detective")
|
||||
assert_bool(bytes.size() > 0).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).is_null()
|
||||
var msg: Dictionary = decoded.value
|
||||
assert_bool(msg.has("character_archetype")).override_failure_message(
|
||||
"StartupMessage must contain 'character_archetype' key, got: %s" % str(msg.keys())
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_protocol_startup_message_detective_maps_to_pascal_case() -> void:
|
||||
# "detective" client string must map to "Detective" PascalCase server enum variant.
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "detective")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_str(decoded.value["character_archetype"]).is_equal("Detective")
|
||||
|
||||
|
||||
func test_protocol_startup_message_smuggler_maps_to_pascal_case() -> void:
|
||||
# "smuggler" client string must map to "Smuggler" PascalCase server enum variant.
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "smuggler")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_str(decoded.value["character_archetype"]).is_equal("Smuggler")
|
||||
|
||||
|
||||
func test_protocol_startup_message_preserves_world_seed() -> void:
|
||||
# Adding character_archetype must not break world_seed encoding.
|
||||
var seed: int = 0xDEADBEEF
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(seed, "detective")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_int(decoded.value["world_seed"]).is_equal(seed)
|
||||
|
||||
|
||||
# -- #590: triangle_crisis_events decode --------------------------------------
|
||||
|
||||
func test_protocol_decode_includes_triangle_crisis_events_field() -> void:
|
||||
# decode_snapshot() must return a "triangle_crisis_events" key (#590).
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"triangle_crisis_events": [{"triangle_id": 42}],
|
||||
}
|
||||
@@ -102,7 +40,7 @@ func test_protocol_decode_triangle_crisis_events_empty_array() -> void:
|
||||
# When no events are present, field is present and empty.
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"triangle_crisis_events": [],
|
||||
}
|
||||
@@ -117,7 +55,7 @@ func test_protocol_decode_triangle_crisis_events_absent_returns_empty() -> void:
|
||||
# When server doesn't send field (pre-#589), field defaults to empty array.
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
@@ -167,7 +105,7 @@ func test_protocol_decode_includes_current_ticker_field() -> void:
|
||||
# decode_snapshot() must return a "current_ticker" key (#592).
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"current_ticker": {"id": "ticker_001", "text": "Station systems nominal.", "category": "System"},
|
||||
}
|
||||
@@ -187,7 +125,7 @@ func test_protocol_decode_current_ticker_null_when_absent() -> void:
|
||||
# When server doesn't send current_ticker (player outside bar zone), field is null.
|
||||
var raw := {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
}
|
||||
var encoded = Messagepack.encode(raw)
|
||||
@@ -210,7 +148,7 @@ func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void:
|
||||
# Snapshot with no current_ticker (player outside bar zone).
|
||||
GameState.current_snapshot = {
|
||||
"tick": 1,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
}
|
||||
ticker.update_from_state()
|
||||
@@ -229,7 +167,7 @@ func test_news_ticker_visible_when_snapshot_has_ticker() -> void:
|
||||
|
||||
GameState.current_snapshot = {
|
||||
"tick": 2,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"version": 23,
|
||||
"entities": [],
|
||||
"current_ticker": {"id": "t1", "text": "Station systems nominal.", "category": "System"},
|
||||
}
|
||||
@@ -249,7 +187,7 @@ func test_news_ticker_hides_when_ticker_becomes_null() -> void:
|
||||
|
||||
# Show it first.
|
||||
GameState.current_snapshot = {
|
||||
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
|
||||
"tick": 1, "version": 23, "entities": [],
|
||||
"current_ticker": {"id": "t1", "text": "Breaking news.", "category": "System"},
|
||||
}
|
||||
ticker.update_from_state()
|
||||
@@ -257,7 +195,7 @@ func test_news_ticker_hides_when_ticker_becomes_null() -> void:
|
||||
|
||||
# Null current_ticker — player left the bar zone.
|
||||
GameState.current_snapshot = {
|
||||
"tick": 2, "version": Protocol.PROTOCOL_VERSION, "entities": [],
|
||||
"tick": 2, "version": 23, "entities": [],
|
||||
}
|
||||
ticker.update_from_state()
|
||||
assert_bool(ticker.visible).override_failure_message(
|
||||
|
||||
@@ -132,7 +132,6 @@ func skip_test_proof_player_moves_and_v2_snapshot() -> void:
|
||||
assert_float(player.y).is_equal_approx(15.5, 0.001)
|
||||
|
||||
# v4 protocol fields present
|
||||
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
||||
assert_that(snapshot.player_facing).is_equal("North")
|
||||
assert_that(snapshot.game_time).is_not_null()
|
||||
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
## Sprint 30 — QA acceptance tests
|
||||
##
|
||||
## Covers all 5 Sprint 30 client tickets:
|
||||
## #718 — Persist CharacterVisualDescriptor on new game start
|
||||
## #719 — Hair highlight: make swatch read-only (Option B — no compositor yet)
|
||||
## #720 — Replace DirAccess scanning with manifest JSON for export builds
|
||||
## #712 — BoneAttachment3D marker above Head bone for floating icons
|
||||
## #674 — Star map insert module (test-first: scene must exist when implemented)
|
||||
##
|
||||
## Test convention:
|
||||
## - Tests that should PASS immediately = regression guards on existing code
|
||||
## - Tests prefixed [ACCEPTANCE] = will FAIL until the ticket is implemented
|
||||
##
|
||||
## Ticket refs: #718, #719, #720, #712, #674
|
||||
class_name TestSprint30
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
# Reset GameState fields touched by #718 tests to avoid cross-test pollution.
|
||||
GameState.character_visual_descriptor = null
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
GameState.character_visual_descriptor = null
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #718 — Persist CharacterVisualDescriptor
|
||||
# =============================================================================
|
||||
|
||||
func test_game_state_has_character_visual_descriptor_field() -> void:
|
||||
## GameState.character_visual_descriptor must exist and default to null.
|
||||
## Confirms the field added in game_state.gd line 103 is present.
|
||||
var gs := GameState.new()
|
||||
auto_free(gs)
|
||||
# The field is declared on the class — access it without error
|
||||
var val: Variant = gs.get("character_visual_descriptor")
|
||||
# Field should exist (not return null from missing property vs. null value)
|
||||
assert_bool(gs.has_method("apply_snapshot")).override_failure_message(
|
||||
"GameState must be a valid autoload class with apply_snapshot"
|
||||
).is_true()
|
||||
# The property itself must be gettable and null by default
|
||||
assert_bool(val == null).override_failure_message(
|
||||
"GameState.character_visual_descriptor must default to null"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_to_dict_includes_all_required_fields() -> void:
|
||||
## CharacterVisualDescriptor.to_dict() must include all wire-format fields.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
var d := desc.to_dict()
|
||||
var required_keys := [
|
||||
"body_type", "head_id", "hair_id", "hair_tint",
|
||||
"facial_hair_id", "facial_hair_tint", "eyebrow_id", "eyebrow_tint",
|
||||
"eye_color", "skin_tone", "clothing_slots", "clothing_tints",
|
||||
"accessory_slots", "accessory_tints",
|
||||
]
|
||||
for key in required_keys:
|
||||
assert_bool(d.has(key)).override_failure_message(
|
||||
"to_dict() must include field '%s'" % key
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_to_dict_body_type_is_wire_string() -> void:
|
||||
## body_type in to_dict() must be a string (rmp_serde unit enum), not an int.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||||
var d := desc.to_dict()
|
||||
assert_str(d["body_type"]).override_failure_message(
|
||||
"to_dict() body_type must be wire string 'AverageM'"
|
||||
).is_equal("AverageM")
|
||||
|
||||
|
||||
func test_descriptor_round_trip_preserves_fields() -> void:
|
||||
## from_dict(to_dict(desc)) must preserve all scalar fields.
|
||||
var original := CharacterVisualDescriptor.new()
|
||||
auto_free(original)
|
||||
original.body_type = CharacterVisualDescriptor.BodyType.THIN_F
|
||||
original.head_id = "head_002"
|
||||
original.hair_id = "bob"
|
||||
original.hair_tint = Color(0.8, 0.4, 0.2)
|
||||
original.skin_tone = 3
|
||||
|
||||
var wire := original.to_dict()
|
||||
var restored := CharacterVisualDescriptor.from_dict(wire)
|
||||
assert_bool(restored != null).override_failure_message(
|
||||
"from_dict() must return a descriptor for valid wire data"
|
||||
).is_true()
|
||||
if restored == null:
|
||||
return
|
||||
|
||||
assert_int(int(restored.body_type)).override_failure_message(
|
||||
"body_type must survive round-trip"
|
||||
).is_equal(int(CharacterVisualDescriptor.BodyType.THIN_F))
|
||||
|
||||
assert_str(restored.head_id).override_failure_message(
|
||||
"head_id must survive round-trip"
|
||||
).is_equal("head_002")
|
||||
|
||||
assert_str(restored.hair_id).override_failure_message(
|
||||
"hair_id must survive round-trip"
|
||||
).is_equal("bob")
|
||||
|
||||
assert_int(restored.skin_tone).override_failure_message(
|
||||
"skin_tone must survive round-trip"
|
||||
).is_equal(3)
|
||||
|
||||
|
||||
func test_descriptor_from_dict_returns_null_when_missing_body_type() -> void:
|
||||
## from_dict() must return null if body_type is absent (required field).
|
||||
var d := {"head_id": "head_001"} # missing body_type
|
||||
var result := CharacterVisualDescriptor.from_dict(d)
|
||||
assert_bool(result == null).override_failure_message(
|
||||
"from_dict() must return null when body_type is missing"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_color_encoding_is_float_array() -> void:
|
||||
## Colors must encode as [r, g, b, a] float arrays for rmp_serde compatibility.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
desc.eye_color = Color(0.1, 0.2, 0.3, 1.0)
|
||||
var d := desc.to_dict()
|
||||
var encoded: Variant = d["eye_color"]
|
||||
assert_bool(encoded is Array).override_failure_message(
|
||||
"eye_color must encode as an Array [r, g, b, a]"
|
||||
).is_true()
|
||||
if not (encoded is Array):
|
||||
return
|
||||
assert_int((encoded as Array).size()).override_failure_message(
|
||||
"eye_color array must have 4 elements"
|
||||
).is_equal(4)
|
||||
assert_float((encoded as Array)[0]).override_failure_message(
|
||||
"eye_color[0] (r) must be approx 0.1"
|
||||
).is_equal_approx(0.1, 0.001)
|
||||
|
||||
|
||||
func test_apply_snapshot_restores_character_visual_descriptor() -> void:
|
||||
## #718: apply_snapshot() must restore character_visual_descriptor from
|
||||
## the "character_visual_descriptor" key in ObserverSnapshot (save/load path).
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F
|
||||
desc.head_id = "head_003"
|
||||
desc.hair_id = "dreads"
|
||||
desc.skin_tone = 5
|
||||
var snapshot := {
|
||||
"character_visual_descriptor": desc.to_dict(),
|
||||
}
|
||||
GameState.apply_snapshot(snapshot)
|
||||
var restored: Variant = GameState.character_visual_descriptor
|
||||
assert_bool(restored != null).override_failure_message(
|
||||
"apply_snapshot() must restore character_visual_descriptor from snapshot"
|
||||
).is_true()
|
||||
if restored != null and restored is CharacterVisualDescriptor:
|
||||
var r := restored as CharacterVisualDescriptor
|
||||
assert_int(int(r.body_type)).override_failure_message(
|
||||
"restored body_type must match"
|
||||
).is_equal(int(CharacterVisualDescriptor.BodyType.MUSCULAR_F))
|
||||
assert_str(r.head_id).override_failure_message(
|
||||
"restored head_id must match"
|
||||
).is_equal("head_003")
|
||||
assert_int(r.skin_tone).override_failure_message(
|
||||
"restored skin_tone must match"
|
||||
).is_equal(5)
|
||||
|
||||
|
||||
func test_apply_snapshot_preserves_descriptor_when_field_absent() -> void:
|
||||
## #718: If snapshot lacks "character_visual_descriptor", the existing field
|
||||
## must not be overwritten (server only sends when descriptor changes).
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.hair_id = "bob"
|
||||
GameState.character_visual_descriptor = desc
|
||||
# Snapshot with no character_visual_descriptor key
|
||||
GameState.apply_snapshot({"tick": 1})
|
||||
var after: Variant = GameState.character_visual_descriptor
|
||||
assert_bool(after != null).override_failure_message(
|
||||
"apply_snapshot() must NOT clear descriptor when field is absent"
|
||||
).is_true()
|
||||
if after is CharacterVisualDescriptor:
|
||||
assert_str((after as CharacterVisualDescriptor).hair_id).override_failure_message(
|
||||
"descriptor must be unchanged after snapshot with no character_visual_descriptor key"
|
||||
).is_equal("bob")
|
||||
|
||||
|
||||
func test_protocol_encode_startup_includes_descriptor() -> void:
|
||||
## #718: Protocol.encode_startup_message() must include "character_visual_descriptor"
|
||||
## in the encoded payload when a descriptor is provided.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
desc.body_type = CharacterVisualDescriptor.BodyType.THIN_M
|
||||
desc.hair_id = "buzzed"
|
||||
var bytes := Protocol.encode_startup_message(12345, "detective", desc)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"encode_startup_message() must produce non-empty bytes"
|
||||
).is_true()
|
||||
# Decode and verify the field is present (Messagepack.decode returns {status, value})
|
||||
var raw = Messagepack.decode(bytes)
|
||||
assert_bool(raw.status == null).override_failure_message(
|
||||
"encode_startup_message() output must be valid msgpack"
|
||||
).is_true()
|
||||
if raw.status != null:
|
||||
return
|
||||
var msg: Dictionary = raw.value as Dictionary
|
||||
assert_bool(msg.has("character_visual_descriptor")).override_failure_message(
|
||||
"StartupMessage must include 'character_visual_descriptor' key when descriptor is provided"
|
||||
).is_true()
|
||||
if msg.has("character_visual_descriptor"):
|
||||
assert_bool(msg["character_visual_descriptor"] is Dictionary).override_failure_message(
|
||||
"character_visual_descriptor in StartupMessage must be a Dictionary"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_protocol_encode_startup_omits_descriptor_when_null() -> void:
|
||||
## #718: encode_startup_message() must still produce valid bytes when descriptor is null.
|
||||
var bytes := Protocol.encode_startup_message(0, "detective", null)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"encode_startup_message() must produce valid bytes even with null descriptor"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_descriptor_has_no_hair_highlight_tint_field() -> void:
|
||||
## CharacterVisualDescriptor must NOT have a hair_highlight_tint field.
|
||||
## The highlight is always auto-derived from hair_tint (Option B of #719).
|
||||
## to_dict() must not include it in the wire format.
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
auto_free(desc)
|
||||
var d := desc.to_dict()
|
||||
assert_bool(d.has("hair_highlight_tint")).override_failure_message(
|
||||
"to_dict() must NOT include hair_highlight_tint — highlight is auto-derived"
|
||||
).is_false()
|
||||
assert_bool("hair_highlight_tint" in desc).override_failure_message(
|
||||
"CharacterVisualDescriptor must not define a hair_highlight_tint property"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #719 — Hair highlight swatch (Option B: read-only, auto-derived)
|
||||
# =============================================================================
|
||||
|
||||
func test_derive_hair_highlight_lightens_primary() -> void:
|
||||
## _derive_hair_highlight() must return primary.lightened(0.3).
|
||||
## Tests the derivation formula in character_creation.gd:1917.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_derive_hair_highlight_lightens_primary: scene not available in headless — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
push_warning("test_derive_hair_highlight_lightens_primary: failed to instantiate — skipping")
|
||||
return
|
||||
auto_free(cc)
|
||||
|
||||
# CharacterCreation._derive_hair_highlight is a private method but testable via call()
|
||||
var primary := Color(0.4, 0.3, 0.5)
|
||||
var expected := primary.lightened(0.3)
|
||||
var result: Variant = cc.call("_derive_hair_highlight", primary)
|
||||
assert_bool(result is Color).override_failure_message(
|
||||
"_derive_hair_highlight must return a Color"
|
||||
).is_true()
|
||||
if not (result is Color):
|
||||
return
|
||||
var r := result as Color
|
||||
assert_float(r.r).override_failure_message("derived highlight.r incorrect").is_equal_approx(expected.r, 0.001)
|
||||
assert_float(r.g).override_failure_message("derived highlight.g incorrect").is_equal_approx(expected.g, 0.001)
|
||||
assert_float(r.b).override_failure_message("derived highlight.b incorrect").is_equal_approx(expected.b, 0.001)
|
||||
|
||||
|
||||
func test_hair_highlight_swatch_exists_in_ui() -> void:
|
||||
## [ACCEPTANCE #719] After fix, _hair_highlight_swatch must be non-null
|
||||
## (a display node must be created in _build_hair_color_dock).
|
||||
## WILL FAIL until #719 is implemented.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_hair_highlight_swatch_exists_in_ui: scene not available in headless — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
add_child(cc)
|
||||
await get_tree().process_frame
|
||||
|
||||
# _hair_highlight_swatch must be set after _ready() builds the hair color dock
|
||||
var swatch: Variant = cc.get("_hair_highlight_swatch")
|
||||
assert_bool(swatch != null).override_failure_message(
|
||||
"[#719] _hair_highlight_swatch must not be null — a display node must be created"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_hair_highlight_swatch_is_not_interactive() -> void:
|
||||
## [ACCEPTANCE #719] The highlight swatch must be non-interactive.
|
||||
## Either mouse_filter = IGNORE, or the node is a ColorRect (not a Button with a callback).
|
||||
## WILL FAIL until #719 is implemented.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_hair_highlight_swatch_is_not_interactive: scene not available — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
add_child(cc)
|
||||
await get_tree().process_frame
|
||||
|
||||
var swatch: Variant = cc.get("_hair_highlight_swatch")
|
||||
if swatch == null:
|
||||
push_warning("test_hair_highlight_swatch_is_not_interactive: swatch not found — #719 not yet implemented")
|
||||
return
|
||||
|
||||
# If swatch is a Control node, mouse_filter must be IGNORE (2)
|
||||
if swatch is Control:
|
||||
var ctrl := swatch as Control
|
||||
assert_int(ctrl.mouse_filter).override_failure_message(
|
||||
"[#719] hair highlight swatch must have mouse_filter=IGNORE (non-interactive)"
|
||||
).is_equal(Control.MOUSE_FILTER_IGNORE)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #720 — Manifest JSON completeness (replaces DirAccess scanning)
|
||||
# =============================================================================
|
||||
|
||||
func test_manifest_json_is_parseable() -> void:
|
||||
## manifest.json must exist and parse as a Dictionary.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
assert_bool(ResourceLoader.exists(path) or FileAccess.file_exists(path)).override_failure_message(
|
||||
"manifest.json must exist at res://assets/characters/manifest.json"
|
||||
).is_true()
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_json_is_parseable: file not openable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
assert_bool(parsed is Dictionary).override_failure_message(
|
||||
"manifest.json must parse as a JSON object (Dictionary)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_hair_includes_all_asset_dirs() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "hair" array must include every .glb in
|
||||
## assets/characters/hair/. Currently missing: balding, buzzed_female, dreads,
|
||||
## long_dreads, mohawk, ponytail_f, simple_parted, slick_back.
|
||||
## WILL FAIL until #720 populates the manifest fully.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_hair_includes_all_asset_dirs: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var hair_list: Array = manifest.get("hair", [])
|
||||
|
||||
# All hair IDs confirmed from assets/characters/hair/*.glb scan (2026-04-04)
|
||||
var expected_hair := [
|
||||
"bald", "balding", "bob", "buns", "buzzed", "buzzed_female",
|
||||
"dreads", "long", "long_dreads", "mohawk", "ponytail", "ponytail_f",
|
||||
"simple_parted", "slick_back",
|
||||
]
|
||||
for hair_id in expected_hair:
|
||||
assert_bool(hair_list.has(hair_id)).override_failure_message(
|
||||
"[#720] manifest 'hair' must include '%s'" % hair_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_heads_are_populated() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "heads" must not be empty.
|
||||
## heads/templates/ contains head_001..head_004 — all must be listed.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_heads_are_populated: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var heads_list: Array = manifest.get("heads", [])
|
||||
|
||||
var expected_heads := ["head_001", "head_002", "head_003", "head_004"]
|
||||
assert_bool(not heads_list.is_empty()).override_failure_message(
|
||||
"[#720] manifest 'heads' must not be empty — 4 head templates exist"
|
||||
).is_true()
|
||||
for head_id in expected_heads:
|
||||
assert_bool(heads_list.has(head_id)).override_failure_message(
|
||||
"[#720] manifest 'heads' must include '%s'" % head_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_body_types_includes_all_11() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "body_types" must include all 11 types.
|
||||
## Currently has 6; missing: thin_m, thin_f, heavy_m, heavy_f, child.
|
||||
## WILL FAIL until #720 updates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_body_types_includes_all_11: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var bt_list: Array = manifest.get("body_types", [])
|
||||
|
||||
var expected_types := [
|
||||
"thin_m", "thin_f", "average_m", "average_f",
|
||||
"muscular_m", "muscular_f", "teen_m", "teen_f",
|
||||
"heavy_m", "heavy_f", "child",
|
||||
]
|
||||
for bt in expected_types:
|
||||
assert_bool(bt_list.has(bt)).override_failure_message(
|
||||
"[#720] manifest 'body_types' must include '%s'" % bt
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_clothing_includes_all_items() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "clothing" must include all items in
|
||||
## assets/characters/clothing/. Currently missing: boots_work, coveralls_basic,
|
||||
## jacket_utility, pants_cargo, shirt_henley.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_clothing_includes_all_items: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var clothing_data: Variant = manifest.get("clothing", {})
|
||||
var clothing_keys: Array = []
|
||||
if clothing_data is Dictionary:
|
||||
clothing_keys = (clothing_data as Dictionary).keys()
|
||||
|
||||
# All clothing item IDs confirmed from assets/characters/clothing/ scan (2026-04-04)
|
||||
var expected_items := [
|
||||
"boots_work", "coveralls_basic", "jacket_utility", "pants_cargo",
|
||||
"peasant_pants", "peasant_shoes", "peasant_tunic", "shirt_henley",
|
||||
]
|
||||
for item_id in expected_items:
|
||||
assert_bool(clothing_keys.has(item_id)).override_failure_message(
|
||||
"[#720] manifest 'clothing' must include '%s'" % item_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_manifest_eyebrows_are_populated() -> void:
|
||||
## [ACCEPTANCE #720] manifest.json "eyebrows" must list all eyebrow styles.
|
||||
## assets/characters/eyebrows/ has: female, regular, teen, thick.
|
||||
## WILL FAIL until #720 populates the manifest.
|
||||
var path := "res://assets/characters/manifest.json"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("test_manifest_eyebrows_are_populated: manifest not readable — skipping")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not (parsed is Dictionary):
|
||||
return
|
||||
var manifest := parsed as Dictionary
|
||||
var eb_list: Array = manifest.get("eyebrows", [])
|
||||
|
||||
var expected_eyebrows := ["female", "regular", "teen", "thick"]
|
||||
assert_bool(not eb_list.is_empty()).override_failure_message(
|
||||
"[#720] manifest 'eyebrows' must not be empty"
|
||||
).is_true()
|
||||
for eb_id in expected_eyebrows:
|
||||
assert_bool(eb_list.has(eb_id)).override_failure_message(
|
||||
"[#720] manifest 'eyebrows' must include '%s'" % eb_id
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_dir_access_scan_functions_removed() -> void:
|
||||
## [ACCEPTANCE #720] After fix, _scan_subdirs and _scan_asset_ids must be
|
||||
## removed from CharacterCreation. These functions fail in exported PCK builds.
|
||||
## WILL FAIL until #720 removes the DirAccess fallbacks.
|
||||
var cc_scene_path := "res://scenes/character_creation.tscn"
|
||||
if not ResourceLoader.exists(cc_scene_path):
|
||||
push_warning("test_dir_access_scan_functions_removed: scene not available — skipping")
|
||||
return
|
||||
var packed := load(cc_scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return
|
||||
var cc := packed.instantiate() as CharacterCreation
|
||||
if cc == null:
|
||||
return
|
||||
auto_free(cc)
|
||||
|
||||
assert_bool(cc.has_method("_scan_subdirs")).override_failure_message(
|
||||
"[#720] _scan_subdirs must be removed — use manifest JSON instead"
|
||||
).is_false()
|
||||
assert_bool(cc.has_method("_scan_asset_ids")).override_failure_message(
|
||||
"[#720] _scan_asset_ids must be removed — use manifest JSON instead"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# #712 — BoneAttachment3D overhead anchor in CharacterVisual
|
||||
# =============================================================================
|
||||
|
||||
func test_character_visual_has_get_overhead_anchor() -> void:
|
||||
## CharacterVisual must expose get_overhead_anchor() as part of its public API.
|
||||
## This is a static assertion — no 3D assets required.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
assert_bool(cv.has_method("get_overhead_anchor")).override_failure_message(
|
||||
"CharacterVisual must have get_overhead_anchor() method (#712)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_overhead_anchor_is_null_before_load() -> void:
|
||||
## get_overhead_anchor() must return null before load_descriptor() is called.
|
||||
## The anchor is created during skeleton load, not at construction.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
var anchor: Variant = cv.get_overhead_anchor()
|
||||
assert_bool(anchor == null).override_failure_message(
|
||||
"get_overhead_anchor() must be null before load_descriptor() is called"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_overhead_anchor_offset_constant() -> void:
|
||||
## [ACCEPTANCE #712] If CharacterVisual exposes the overhead anchor offset
|
||||
## as a constant or via get_overhead_anchor(), the offset must be Vector3(0, 0.3, 0).
|
||||
## Verified via code inspection: _overhead_anchor.position = Vector3(0, 0.3, 0).
|
||||
## This test loads a scene and verifies if assets are present.
|
||||
var cv := CharacterVisual.new()
|
||||
auto_free(cv)
|
||||
add_child(cv)
|
||||
|
||||
# Without GLB assets available in headless, skeleton load is a no-op.
|
||||
# Check that _overhead_attachment is also null before load (belt-and-suspenders).
|
||||
var attachment: Variant = cv.get("_overhead_attachment")
|
||||
assert_bool(attachment == null).override_failure_message(
|
||||
"_overhead_attachment must be null before skeleton is loaded"
|
||||
).is_true()
|
||||
@@ -1,292 +0,0 @@
|
||||
## Sprint 16 #540: Sprite integration tests.
|
||||
## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64
|
||||
## bounding box, sprite asset existence from #541, and fog shader independence.
|
||||
## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541.
|
||||
class_name TestSpriteIntegration
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
# -- Footprint constants: D-044 spec (S16-S01, S16-S02) -----------------------
|
||||
|
||||
func test_entity_footprint_matches_d044_spec() -> void:
|
||||
# S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile.
|
||||
# (64x64 source sprite scaled to 32px runtime at 2x retina per D-066).
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message(
|
||||
"D-044: ENTITY_WIDTH must be 24px"
|
||||
).is_equal(24)
|
||||
assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message(
|
||||
"D-044: ENTITY_HEIGHT must be 32px"
|
||||
).is_equal(32)
|
||||
|
||||
|
||||
func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void:
|
||||
# S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile
|
||||
# bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction
|
||||
# range (2 sim tiles) accurate with the tilted perspective.
|
||||
var tile_2x: int = Constants.TILE_SIZE * 2
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message(
|
||||
"D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [
|
||||
EntityRenderer.ENTITY_WIDTH, tile_2x]
|
||||
).is_true()
|
||||
assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message(
|
||||
"D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [
|
||||
EntityRenderer.ENTITY_HEIGHT, tile_2x]
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_entity_width_fits_within_single_tile() -> void:
|
||||
# S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile.
|
||||
# Ensures horizontal centering offset is positive and entity doesn't overflow.
|
||||
assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message(
|
||||
"Entity width must be less than TILE_SIZE for centered layout"
|
||||
).is_true()
|
||||
assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message(
|
||||
"ENTITY_OFFSET_X must be non-negative for horizontal centering"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Pixel position (S16-S04) -------------------------------------------------
|
||||
|
||||
func test_entity_pixel_position_at_tile_3_7() -> void:
|
||||
# S16-S04: Entity at tile (3.0, 7.0) → pixel position must be
|
||||
# (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y).
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[10]
|
||||
var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X
|
||||
var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||
assert_that(node.position.x).override_failure_message(
|
||||
"Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X"
|
||||
).is_equal_approx(expected_x, 0.1)
|
||||
assert_that(node.position.y).override_failure_message(
|
||||
"Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y"
|
||||
).is_equal_approx(expected_y, 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) -----------------------
|
||||
|
||||
func test_z_sort_south_entity_has_higher_pixel_y() -> void:
|
||||
# S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y
|
||||
# than entity at y=4 (north). Godot y-sort renders higher-y on top.
|
||||
# With tilted sprites, south-facing entity must visually overlap northern.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var north_node = renderer.entity_nodes[20]
|
||||
var south_node = renderer.entity_nodes[21]
|
||||
assert_that(south_node.position.y > north_node.position.y).override_failure_message(
|
||||
"Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort"
|
||||
).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_z_sort_y_position_difference_equals_tile_size() -> void:
|
||||
# S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE.
|
||||
# Verifies position calculation is consistent for adjacent tiles.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var node3 = renderer.entity_nodes[30]
|
||||
var node4 = renderer.entity_nodes[31]
|
||||
var delta_y := node4.position.y - node3.position.y
|
||||
assert_that(delta_y).override_failure_message(
|
||||
"Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE
|
||||
).is_equal_approx(float(Constants.TILE_SIZE), 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_z_sort_same_y_different_x_no_y_difference() -> void:
|
||||
# S16-S07: Two entities at same y but different x → same pixel.y.
|
||||
# Horizontal position must not affect y-sort order.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entities := [
|
||||
{"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
renderer.update_entities(entities)
|
||||
var left_node = renderer.entity_nodes[40]
|
||||
var right_node = renderer.entity_nodes[41]
|
||||
assert_that(left_node.position.y).override_failure_message(
|
||||
"Entities at same y-tile must have same pixel.y regardless of x"
|
||||
).is_equal_approx(right_node.position.y, 0.1)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Sprite assets from #541 (S16-S08, S16-S09) --------------------------------
|
||||
|
||||
func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||
# S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions.
|
||||
# entity_renderer.gd must be able to load these paths.
|
||||
for direction in ["north", "east", "south", "west"]:
|
||||
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
|
||||
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||
"NPC sprite missing: %s" % path
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void:
|
||||
# S16-S09: #541 delivers 64px wall sprites for all four cardinal directions.
|
||||
for direction in ["north", "east", "south", "west"]:
|
||||
var path := "res://assets/sprites/wall_structural_%s_64.png" % direction
|
||||
assert_that(ResourceLoader.exists(path)).override_failure_message(
|
||||
"Wall sprite missing: %s" % path
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Fog shader independence: D-019 (S16-S10, S16-S11) -----------------------
|
||||
|
||||
func test_fog_shader_script_and_gdshader_load_correctly() -> void:
|
||||
# S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite
|
||||
# changes. D-019: "fog vision cone math remains pure 2D" — unaffected by
|
||||
# the art-direction tilt baked into sprites.
|
||||
assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message(
|
||||
"fog_shader.gd must load correctly — must not be affected by sprite changes"
|
||||
).is_true()
|
||||
assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||
"fog.gdshader must exist — fog is screen-space and sprite-independent"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_update_runs_independently_of_entity_renderer_state() -> void:
|
||||
# S16-S11: FogState.update_from_state() must succeed with no entity renderer
|
||||
# active. D-019: fog driven by LOS mask (visible_positions), not sprites.
|
||||
var fog = get_node_or_null("/root/FogState")
|
||||
if fog == null:
|
||||
push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped")
|
||||
return
|
||||
# Provide visibility data but no entity renderer context
|
||||
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||
GameState.visibility_sectors = {
|
||||
Vector2i(5, 5): "Forward",
|
||||
Vector2i(6, 5): "Peripheral",
|
||||
}
|
||||
if fog.has_method("update_from_state"):
|
||||
fog.update_from_state()
|
||||
assert_that(fog.visibility_texture).override_failure_message(
|
||||
"FogState visibility_texture must be populated independently of sprite state"
|
||||
).is_not_null()
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
# -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) --------
|
||||
|
||||
func test_octant_north_maps_to_north() -> void:
|
||||
# S16-S12: "North" → "north"
|
||||
assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north")
|
||||
|
||||
func test_octant_northwest_maps_to_north() -> void:
|
||||
# S16-S13: "Northwest" → "north" (grouped with North per mapping spec)
|
||||
assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north")
|
||||
|
||||
func test_octant_northeast_maps_to_east() -> void:
|
||||
# S16-S14: "Northeast" → "east"
|
||||
assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east")
|
||||
|
||||
func test_octant_east_maps_to_east() -> void:
|
||||
# S16-S15: "East" → "east"
|
||||
assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east")
|
||||
|
||||
func test_octant_southeast_maps_to_south() -> void:
|
||||
# S16-S16: "Southeast" → "south"
|
||||
assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south")
|
||||
|
||||
func test_octant_south_maps_to_south() -> void:
|
||||
# S16-S17: "South" → "south"
|
||||
assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south")
|
||||
|
||||
func test_octant_southwest_maps_to_west() -> void:
|
||||
# S16-S18: "Southwest" → "west"
|
||||
assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west")
|
||||
|
||||
func test_octant_west_maps_to_west() -> void:
|
||||
# S16-S19: "West" → "west"
|
||||
assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west")
|
||||
|
||||
func test_octant_unknown_string_falls_back_to_south() -> void:
|
||||
# S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019)
|
||||
assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south")
|
||||
assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south")
|
||||
|
||||
func test_octant_empty_string_falls_back_to_south() -> void:
|
||||
# S16-S21: Empty string → "south" fallback
|
||||
assert_that(EntityRenderer._octant_to_direction("")).is_equal("south")
|
||||
|
||||
|
||||
# -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ----------
|
||||
|
||||
func test_entity_direction_npc_always_south() -> void:
|
||||
# S16-S22: NPC entity → always "south" regardless of any data field.
|
||||
# NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle).
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
# entity_id 99 is not the player
|
||||
var dir := renderer._entity_direction(99, {"entity_id": 99,
|
||||
"kind": {"variant": "Npc", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"NPC entity must always return 'south'"
|
||||
).is_equal("south")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_uses_player_facing() -> void:
|
||||
# S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction.
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "North"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='North' must return 'north'"
|
||||
).is_equal("north")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_facing_east() -> void:
|
||||
# S16-S24: Player facing "East" → "east"
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "East"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='East' must return 'east'"
|
||||
).is_equal("east")
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void:
|
||||
# S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping).
|
||||
# Diagonal octants map to one of the four cardinal sprite sets.
|
||||
var renderer := _make_entity_renderer()
|
||||
GameState.player_entity_id = 1
|
||||
GameState.player_facing = "Northwest"
|
||||
var dir := renderer._entity_direction(1, {"entity_id": 1,
|
||||
"kind": {"variant": "Player", "data": null}})
|
||||
assert_that(dir).override_failure_message(
|
||||
"Player entity with player_facing='Northwest' must return 'north'"
|
||||
).is_equal("north")
|
||||
renderer.queue_free()
|
||||
@@ -1 +0,0 @@
|
||||
uid://b8snipm64g2b2
|
||||
@@ -291,13 +291,15 @@ func test_hud_time_row_updates_after_process() -> void:
|
||||
add_child(instance)
|
||||
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
|
||||
"tick": 1, "version": 23, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
|
||||
})
|
||||
instance._process(0.016)
|
||||
|
||||
# In test mode SimBridge is disconnected — poll_snapshot() returns null so the
|
||||
# SnapshotEventRouter inside main._process() never fires. Call the HUD directly
|
||||
# instead, which is what the router would do in a live session.
|
||||
var hud = instance.get_node_or_null("InsertOverlay/HUD")
|
||||
assert_that(hud).is_not_null()
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_time_text()).is_equal("12:00 · Afternoon · D1")
|
||||
|
||||
|
||||
|
||||
@@ -498,7 +498,8 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
||||
if is_instance_valid(panel):
|
||||
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
|
||||
|
||||
confrontation_monologue.emit(
|
||||
UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION
|
||||
@@ -625,8 +626,11 @@ func _format_entry(entry: Dictionary, alpha: float) -> String:
|
||||
|
||||
|
||||
## Escape BBCode bracket characters in server-sourced text (Hoshe #2).
|
||||
## #866 fix: only escape '[' — unmatched ']' renders as a literal in RichTextLabel.
|
||||
## Chaining .replace("]", "[rb]") after .replace("[", "[lb]") corrupted the [lb] escape
|
||||
## itself: "[lb]" → "[lb[rb]", making the BBCode injection guard non-functional.
|
||||
static func _escape_bbcode(text: String) -> String:
|
||||
return text.replace("[", "[lb]").replace("]", "[rb]")
|
||||
return text.replace("[", "[lb]")
|
||||
|
||||
|
||||
## Assign a palette color to an NPC entity ID on first encounter (#573).
|
||||
|
||||
@@ -82,12 +82,16 @@ func _start_fade_out() -> void:
|
||||
|
||||
|
||||
## Dismiss immediately (e.g. when dialogue opens).
|
||||
## Sets _active = false immediately so is_active() returns false before the fade completes.
|
||||
func dismiss() -> void:
|
||||
if not _active:
|
||||
return
|
||||
_active = false
|
||||
if _dismiss_tween and _dismiss_tween.is_valid():
|
||||
_dismiss_tween.kill()
|
||||
_start_fade_out()
|
||||
var t := create_tween()
|
||||
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
|
||||
t.tween_callback(func(): visible = false)
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
|
||||
@@ -69,9 +69,9 @@ func _draw() -> void:
|
||||
else:
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), Color(0.05, 0.07, 0.10, 1.0))
|
||||
|
||||
# Political zone tint (currency zone band — single tint over whole body for MVP)
|
||||
# Political zones — province boundaries from drainage analysis
|
||||
if viewer.is_overlay_visible("political_zones"):
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(tex_w, tex_h)), COLOR_POLITICAL)
|
||||
_draw_province_boundaries(markers)
|
||||
|
||||
# Infrastructure (roads + rail)
|
||||
if viewer.is_overlay_visible("infrastructure"):
|
||||
@@ -238,6 +238,39 @@ static func _city_key(city: Dictionary) -> String:
|
||||
return "h:%d" % city.hash()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Province boundaries (D-205, #927)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55)
|
||||
const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08)
|
||||
const PROVINCE_BORDER_WIDTH: float = 1.2
|
||||
|
||||
|
||||
func _draw_province_boundaries(markers: Dictionary) -> void:
|
||||
var provinces: Array = markers.get("provinces", [])
|
||||
if provinces.is_empty():
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(viewer.get_heightmap_texture().get_width(), viewer.get_heightmap_texture().get_height())), COLOR_POLITICAL)
|
||||
return
|
||||
for prov: Dictionary in provinces:
|
||||
var path: Array = prov.get("path", [])
|
||||
if path.size() < 3:
|
||||
continue
|
||||
var points: PackedVector2Array = _province_path_to_canvas(path)
|
||||
if points.size() >= 3:
|
||||
draw_colored_polygon(points, COLOR_PROVINCE_FILL)
|
||||
draw_polyline(points, COLOR_PROVINCE_BORDER, PROVINCE_BORDER_WIDTH, true)
|
||||
|
||||
|
||||
func _province_path_to_canvas(path: Array) -> PackedVector2Array:
|
||||
var out: PackedVector2Array = PackedVector2Array()
|
||||
for pt: Variant in path:
|
||||
if pt is Array and pt.size() >= 2:
|
||||
out.append(viewer.grid_to_canvas(Vector2(float(pt[1]), float(pt[0]))))
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Overlay placeholders (populated by server side signals eventually)
|
||||
# =============================================================================
|
||||
|
||||
@@ -1611,7 +1611,7 @@ func _take_screenshot(suffix: String = "") -> void:
|
||||
# Take screenshot for current cardinal, then advance. Single array for
|
||||
# both facing and filename label — previously two arrays with different
|
||||
# orderings produced swapped labels at indices 1 and 3.
|
||||
var dir_name := CARDINAL_DIRS[_screenshot_cardinal_idx]
|
||||
var dir_name: String = CARDINAL_DIRS[_screenshot_cardinal_idx]
|
||||
_char_visual.set_facing(dir_name)
|
||||
suffix = dir_name
|
||||
|
||||
|
||||
@@ -39,13 +39,9 @@ func _build_ui() -> void:
|
||||
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_label)
|
||||
|
||||
# client_ver and proto_ver are independent — project.yaml version is the client release,
|
||||
# Protocol.PROTOCOL_VERSION is the wire protocol. Mismatches between builds are visible
|
||||
# only to the observer reading the loading-screen label; a future ticket will surface them.
|
||||
var client_ver := _read_client_version()
|
||||
var proto_ver: int = Protocol.PROTOCOL_VERSION
|
||||
_version_label = Label.new()
|
||||
_version_label.text = "v%s · protocol %d" % [client_ver, proto_ver]
|
||||
_version_label.text = "v%s" % [client_ver]
|
||||
_version_label.add_theme_font_size_override("font_size", VERSION_FONT_SIZE)
|
||||
_version_label.add_theme_color_override("font_color", VERSION_COLOR)
|
||||
_version_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
extends MetaScreen
|
||||
extends "res://ui/meta/meta_screen.gd"
|
||||
## #258: Main menu — New Game / Continue / Load Game / Quit.
|
||||
## New Game: opens character creation screen, then starts game.
|
||||
## Continue: loads most recent save directory.
|
||||
|
||||
@@ -9,7 +9,6 @@ extends Control
|
||||
# (>= tiebreak = FIFO: newest replaces oldest at same priority).
|
||||
#
|
||||
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
|
||||
# Colour: lattice_profile passed in at call time — no autoload access in renderer.
|
||||
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
|
||||
|
||||
const MAX_VISIBLE: int = 3
|
||||
@@ -20,29 +19,16 @@ const FADE_IN_SEC: float = 0.3
|
||||
const FADE_OUT_SEC: float = 0.5
|
||||
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
|
||||
|
||||
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
|
||||
# standard opacity = 0.85, urgent opacity = 1.0.
|
||||
# Monologue colour palette — standard opacity = 0.85, urgent opacity = 1.0.
|
||||
# Source: Tyre architecture review, Sprint 14.
|
||||
const _LATTICE_COLORS: Dictionary = {
|
||||
"lattice_augmented":
|
||||
{ # detective
|
||||
"standard": Color("#d0d4e0"),
|
||||
"urgent": Color("#e0e8f8"),
|
||||
},
|
||||
"lattice_baseline":
|
||||
{ # smuggler
|
||||
"standard": Color("#d8d0c4"),
|
||||
"urgent": Color("#f0e4d4"),
|
||||
},
|
||||
}
|
||||
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
const _STANDARD_COLOR: Color = Color("#c8d0e0")
|
||||
const _URGENT_COLOR: Color = Color("#e0e8f8")
|
||||
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
|
||||
const _NOTIFICATION_DURATION: float = 2.5
|
||||
|
||||
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
|
||||
var _visible: Array[Dictionary] = []
|
||||
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
|
||||
# Queue entry: {text, duration, priority, is_urgent}
|
||||
var _queue: Array[Dictionary] = []
|
||||
# Msec timestamp when the next fade-in may begin (stagger enforcement)
|
||||
var _next_fade_in_msec: float = 0.0
|
||||
@@ -69,19 +55,15 @@ func _process(delta: float) -> void:
|
||||
if next.get("is_notification", false):
|
||||
_show_notification_line(next.text)
|
||||
else:
|
||||
_show_line(
|
||||
next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile
|
||||
)
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent)
|
||||
|
||||
|
||||
# Display a monologue line.
|
||||
# priority: higher number = more important (default 2; urgent beats normal).
|
||||
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
|
||||
# Empty text is silently ignored — no slot created, no queue entry.
|
||||
# lattice_profile is read from GameState here and passed down — renderer stays
|
||||
# decoupled from the autoload (D-020 renderer contract).
|
||||
# #554: Show a brief system notification (save/load result, connection status).
|
||||
# Uses neutral color, short duration, bypasses lattice_profile styling.
|
||||
# Uses neutral color, short duration.
|
||||
func show_notification(text: String) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
@@ -94,7 +76,6 @@ func show_notification(text: String) -> void:
|
||||
duration = _NOTIFICATION_DURATION,
|
||||
priority = 1,
|
||||
is_urgent = false,
|
||||
lattice_profile = "",
|
||||
is_notification = true
|
||||
}
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
@@ -116,12 +97,11 @@ func show_monologue(
|
||||
) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
var profile := GameState.lattice_profile
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
||||
_show_line(text, duration, priority, is_urgent, profile)
|
||||
_show_line(text, duration, priority, is_urgent)
|
||||
else:
|
||||
_enqueue(text, duration, priority, is_urgent, profile)
|
||||
_enqueue(text, duration, priority, is_urgent)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -159,9 +139,9 @@ func _show_notification_line(text: String) -> void:
|
||||
|
||||
|
||||
func _show_line(
|
||||
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
|
||||
text: String, duration: float, priority: int, is_urgent: bool
|
||||
) -> void:
|
||||
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
||||
var line_node := _build_line_node(text, is_urgent)
|
||||
_vbox.add_child(line_node)
|
||||
|
||||
var slot := {
|
||||
@@ -192,7 +172,7 @@ func _retire_slot(slot: Dictionary) -> void:
|
||||
|
||||
|
||||
func _enqueue(
|
||||
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
|
||||
text: String, duration: float, priority: int, is_urgent: bool
|
||||
) -> void:
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append(
|
||||
@@ -201,7 +181,6 @@ func _enqueue(
|
||||
duration = duration,
|
||||
priority = priority,
|
||||
is_urgent = is_urgent,
|
||||
lattice_profile = lattice_profile
|
||||
}
|
||||
)
|
||||
_queue.sort_custom(
|
||||
@@ -216,7 +195,6 @@ func _enqueue(
|
||||
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
|
||||
@@ -232,13 +210,8 @@ func _lowest_priority_idx() -> int:
|
||||
return idx
|
||||
|
||||
|
||||
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
|
||||
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
|
||||
var color: Color = (
|
||||
palette.get("urgent", _FALLBACK_URGENT)
|
||||
if is_urgent
|
||||
else palette.get("standard", _FALLBACK_STANDARD)
|
||||
)
|
||||
func _build_line_node(text: String, is_urgent: bool) -> Control:
|
||||
var color: Color = _URGENT_COLOR if is_urgent else _STANDARD_COLOR
|
||||
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
|
||||
+477
-1
@@ -761,4 +761,480 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
---
|
||||
|
||||
*54 decisions. Last updated: 2026-04-21 (D-192 — drop PROTOCOL_VERSION lockstep handshake, sprint 36 client triage)*
|
||||
### D-194: Three-Component District Mix Algorithm for City District Type Distribution
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** District type distribution for a generated city is computed from three components combined at generation time:
|
||||
1. **Population tier guarantees** — minimum district counts enforced by city size. Population tier is `floor(log10(pop / 1_000_000))`, capped at 5. Larger populations guarantee minimum counts of Transit, Commercial, and Residential districts.
|
||||
2. **10×9 economic multiplier table** — rows are 10 `economic_role` values (manufacturing, financial, agricultural, extraction, service_mixed, institutional, transit_hub, research, military, residential); columns are 9 `DistrictType` variants. Each cell is a weight multiplier (0.0–3.0) applied to that district type's base probability for cities of that economic role.
|
||||
3. **Political archetype modifiers** — `PoliticalArchetype` shifts weights for Institutional, Restricted-access, and Civic district types. Corporate archetype boosts Commercial + Restricted. Commission archetype boosts Institutional + Administrative. Pioneer archetype boosts Mixed-use + Organic residential.
|
||||
- **Founding age character** is applied as a post-mix adjustment to `BlockIrregularity` (see D-216), not to the district type distribution itself.
|
||||
- The mix is self-contained per city: two cities with the same economic role, population tier, and political archetype produce the same district type distribution (modulo seed-driven noise). No city-to-city state dependency.
|
||||
- Integer weights throughout — no f32 for D-010 determinism.
|
||||
- **Rationale:** Economic role should visibly shape a city's physical form. A financial hub looks different from a mining hub. Population tier prevents cities from being too small to sustain their economic function. Political archetype encodes power structure in spatial form — Corporate settlements are commercially dense, Commission settlements are institutionally heavy. The three-component model is the minimum set to produce legible variety; adding more inputs risks over-constraining the generator.
|
||||
- **Ticket:** #920
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline), D-213 (DistrictType enum), D-214 (PoliticalArchetype), D-216 (BlockIrregularity)
|
||||
|
||||
### D-195: Attractor-Matching Compatibility Matrix for Generative City Placement
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** City placement on a planetary surface uses an attractor-matching model. A `GeographicAttractor` is a terrain feature that increases city placement score at nearby positions. Seven `AttractorType` variants: `RiverMouth`, `CoastalAccess`, `RiverCrossing`, `ValleyFloor`, `PassEntrance`, `LakeShore`, `PlainCenter`. A `CompatibilityMatrix` is a 10×7 scoring table (10 `economic_role` values × 7 attractor types) whose cells are float weights (0.0–3.0) representing how strongly that economic role favors that terrain feature. Examples: manufacturing → RiverMouth 2.8, ValleyFloor 2.1; financial → CoastalAccess 2.5, PlainCenter 1.8; agricultural → ValleyFloor 3.0, PlainCenter 2.5. The matrix is authored data (not computed at runtime). Attractor extraction from heightmaps is defined in D-209. Matching algorithm is defined in D-211.
|
||||
- **Rationale:** Terrain-naive city placement produces spatially incoherent worlds. The compatibility matrix gives different city types different terrain affinities, so financial hubs appear on coasts and agricultural cities appear in fertile valleys — without hard-coding placement rules per city type. The float weight matrix gives graduated preference, not binary requirement.
|
||||
- **Ticket:** #919, #925
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline), D-208 (D8 drainage — attractor extraction), D-209 (feature tag extraction), D-211 (attractor-matching pipeline)
|
||||
|
||||
### D-196: SettlementClass Enum and Latent Settlement Active/Ghost Logic
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Every settlement (city marker in `atlas_city_names`) has a `SettlementClass` that determines how it enters and exits active simulation:
|
||||
```rust
|
||||
enum SettlementClass {
|
||||
NameLocked, // Named in wiki; always active regardless of population
|
||||
PopulationBudget, // Active if pop > threshold; ghost if below
|
||||
EconomicTriggered, // Active only while economic role condition is met
|
||||
OrganicGrowth, // Emergent; generated by simulation, no prior wiki record
|
||||
}
|
||||
```
|
||||
- **Active threshold** (applies to `PopulationBudget`): population ≥ 50,000 for a city to receive full Phase 1 district skeleton generation. Below threshold: 1-district stub with Minimal ComplexityTier.
|
||||
- **Ghost threshold** (applies to `PopulationBudget`): population < 5,000. Settlement is present in atlas data but receives no NPC population; structures are generated as abandoned (Worn/Derelict condition baseline).
|
||||
- `NameLocked` settlements bypass both thresholds — they are always simulated regardless of population (handles narrative-significant small towns).
|
||||
- `EconomicTriggered` settlements collapse to ghost state when their triggering economic condition lapses (e.g., a mining outpost depopulates when the mine is exhausted).
|
||||
- `OrganicGrowth` settlements are not in `atlas_city_names` at generation time; they are written to the table during simulation when a settlement emerges organically.
|
||||
- **Rationale:** Not every named location needs full generation, and not every simulated location is named. The classification separates authorial intent (NameLocked) from economic reality (PopulationBudget, EconomicTriggered) and simulation emergence (OrganicGrowth). Ghost settlements are important for world texture — abandoned mining towns and depopulated frontier outposts are as legible as thriving hubs.
|
||||
- **Ticket:** #913
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline), D-200 (CityGenerationContext), D-203 (BodyWorldState), D-207 (atlas_city_names)
|
||||
|
||||
### D-197: prosperity_baseline Derivation Formula with Topographic Gradient
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Each city's `prosperity_baseline` (f32, 0.0–1.0, used as economic pressure state seed) is derived at generation time from four components:
|
||||
1. **Economic role base** — lookup per `economic_role` value: manufacturing=0.55, financial=0.70, agricultural=0.50, extraction=0.45, service_mixed=0.60, institutional=0.65, transit_hub=0.60, research=0.65, military=0.55, residential=0.50.
|
||||
2. **Population log-scale bonus** — `0.04 × floor(log10(pop / 1_000_000 + 1))`, capped at +0.12. Larger cities are generally more prosperous.
|
||||
3. **Topographic gradient bonus** — terrain features that historically correlate with prosperity add to the baseline: river mouth +0.08, coastal access +0.06, valley floor +0.04, pass entrance +0.03. At most one terrain bonus applies (the highest-scoring attractor at the city's position).
|
||||
4. **Seed noise** — ±0.05 uniform noise applied last (integer-seeded per city, D-010 determinism).
|
||||
- Formula: `base + pop_bonus + terrain_bonus + noise`, clamped to [0.1, 0.95].
|
||||
- `prosperity_baseline` is not the current prosperity level — it is the simulation's starting point and decay/growth target. The live pressure simulation (D-026) drifts from this value based on trade flows, events, and faction pressure.
|
||||
- **Rationale:** A flat random baseline produces economically incoherent worlds. Terrain-informed prosperity encodes real-world patterns: port cities are wealthy, river-mouth cities are strategic. The log-scale population bonus prevents megacities from dominating without eliminating small-city character. Clamping to [0.1, 0.95] prevents degenerate all-thriving or all-collapsing starting states.
|
||||
- **Ticket:** #920 (consumer of prosperity_baseline)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-194 (district mix — consumes prosperity_baseline), D-195 (attractor types that produce terrain bonus)
|
||||
|
||||
### D-198: Economic Simulation Independence from Layer 1–2 Spatial Data
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** The economics simulation (Phase 2, D-026 background tier) runs independently of Layer 1 (galaxy graph) and Layer 2 (location profiles / planetary topography). Economic state is seeded at game start from `systems.db` data (economic roles, trade flows, corporate presence) and then drifts via the pressure simulation. The generator (Layer 7 district skeleton) reads economic pressure state as an input but does not feed back into the simulation model. The two layers communicate one-way: simulation → generator (pressure state used to set district condition and density), never generator → simulation.
|
||||
- **Prohibited:** Generator code must not modify `PressureState`. Generator code must not query live simulation state during async background generation tasks (race condition risk). Generator reads a snapshot of pressure state taken at generation dispatch time.
|
||||
- **Allowed:** The generator reads `economic_health`, `prosperity_baseline`, `industries`, and `faction_influence` from the snapshot. These are read-only inputs to Phase 1 skeleton classification and Phase 2 condition application.
|
||||
- **Rationale:** Bidirectional coupling between generator and simulation creates initialization order dependencies and potential circular references. The one-way data flow (simulation → generator snapshot → generator) keeps both systems independently testable and avoids race conditions in the Rayon thread pool (D-206). The generator is a consumer of economic state, not a participant in economic evolution.
|
||||
- **Ticket:** #915 (CityGenerationContext reads economic snapshot)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-026 (simulation tiers), D-200 (CityGenerationContext), D-206 (background generation queue)
|
||||
|
||||
### D-199: 6-Field Minimum Economic Read Set for City Generation Context
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** When building a `CityGenerationContext` (D-200), the generator reads exactly 6 fields from the economic pressure snapshot per city. Reading more fields is permitted but these 6 are the minimum required for correct Phase 1 skeleton classification:
|
||||
1. `economic_role` — primary function of the city (determines DistrictType distribution via D-194)
|
||||
2. `prosperity_baseline` — starting economic health (0.0–1.0, see D-197)
|
||||
3. `population` — city population (determines ComplexityTier ceiling, BlockSkeleton density)
|
||||
4. `dominant_faction` — faction with highest `faction_influence` at this location (affects Institutional and Restricted district bias)
|
||||
5. `founding_age_years` — years since settlement founding (drives BlockIrregularity via D-216, era distribution)
|
||||
6. `settlement_class` — `SettlementClass` enum value (D-196, determines whether to generate at all)
|
||||
- Fields 1–5 are read from `systems.db` (bodies table + economics tables). Field 6 is derived at generator dispatch time.
|
||||
- All 6 fields must be present before a generation task is dispatched. Missing fields abort the task with a logged error; generation does not proceed with partial context.
|
||||
- **Rationale:** A fixed minimum read set prevents generators from accumulating unbounded dependencies on simulation state. The 6 fields cover the minimum information needed to produce a correctly-classified skeleton. The abort-on-missing-fields rule ensures generator output is always deterministic from a complete context, never silently degraded from a partial one.
|
||||
- **Ticket:** #915 (CityGenerationContext implementation)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-196 (SettlementClass), D-197 (prosperity_baseline), D-198 (economic simulation independence), D-200 (CityGenerationContext struct)
|
||||
|
||||
### D-200: Three-Tier Execution Model (Build-Time / Runtime-Background / Runtime-On-Demand)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** The generation pipeline operates at three distinct execution tiers with no cross-tier mutation:
|
||||
1. **Build-time (Python pipeline):** Runs `make regen-db`. Produces `systems.db` tables including `atlas_body_heightmaps`, `atlas_city_names`, `atlas_province_boundaries`, `body_radius_km`. Output is a static artifact committed to the repo. Never runs during gameplay.
|
||||
2. **Runtime-background (Rayon thread pool, D-206):** Triggered by content-spidering events (player approaches a system, NPC names a location, news ticker references a place). Runs D8 drainage analysis (D-208), attractor extraction (D-209), settlement placement, and Phase 1 district skeleton generation. Output goes into `BodyWorldState` cache (D-203). Transparent to main tick thread.
|
||||
3. **Runtime-on-demand (main tick thread):** Triggered when the player crosses a chunk boundary. Runs Phase 2 chunk fill for the approaching chunk. Must complete within 5ms. Reads from `BodyWorldState` cache (always populated before this tier runs).
|
||||
- **Tier boundary rules:**
|
||||
- Build-time outputs are read-only at runtime.
|
||||
- Runtime-background tasks read from `systems.db` and write to `BodyWorldState` only.
|
||||
- Runtime-on-demand reads from `BodyWorldState` and writes to the active ECS world (chunk tile data, NPC spawns).
|
||||
- No tier may write to a higher tier's outputs. No circular dependencies.
|
||||
- `CityGenerationContext` struct (see below) is the data contract between tiers 1→2.
|
||||
```rust
|
||||
struct CityGenerationContext {
|
||||
city_id: u64,
|
||||
political_archetype: PoliticalArchetype,
|
||||
prosperity_baseline: f32,
|
||||
surrounding_biome: SettingType,
|
||||
road_entry_directions: Vec<u8>, // compass octants (0–7)
|
||||
footprint_radius_km: f32,
|
||||
founding_orientation: FoundingOrientation,
|
||||
world_tier: WorldTier,
|
||||
}
|
||||
```
|
||||
- **Rationale:** Three tiers with explicit boundaries eliminates the "where does this code run?" question. Build-time is deterministic and committable. Runtime-background is parallelizable. Runtime-on-demand has strict latency budgets. Cross-tier mutation would create race conditions between the Rayon thread pool and the main tick thread.
|
||||
- **Ticket:** #915
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-026 (simulation tiers), D-194 (district mix), D-203 (BodyWorldState), D-206 (background generation queue), tyre-sw1r3.md Layer 5/6 architecture
|
||||
|
||||
### D-201: Spatial Hierarchy — Eight Tiers with Locked Dimensions
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** The generation pipeline has eight spatial tiers from galaxy to tile. Dimensions are locked and cannot be changed without amending this decision:
|
||||
|
||||
| Tier | Name | Dimensions | Purpose |
|
||||
|------|------|------------|---------|
|
||||
| 1 | Galaxy | 300 systems | Galaxy graph, gate topology, cultural corridors |
|
||||
| 2 | System | — | Orbital mechanics, body catalog |
|
||||
| 3 | Body | ~512×256 pixels (equirectangular heightmap) | Planetary topography, climate zones |
|
||||
| 4 | Region | ~50–500km | Province boundaries (watershed-derived, D-205), biome zones |
|
||||
| 5 | Settlement | ~1–30km radius | City footprint, district layout |
|
||||
| 6 | District | 512×512 sim tiles (256m) | Phase 1 skeleton, 4×4 block grid (D-094) |
|
||||
| 7 | Block | 128×128 sim tiles (64m) | Generator planning unit, 2×2 chunks (D-094) |
|
||||
| 8 | Chunk | 64×64 sim tiles (32m) | Streaming/serialization unit (D-094) |
|
||||
|
||||
- Tiers 6–8 are locked by D-094 (district spatial hierarchy). This decision formalizes Tiers 1–5 with equivalent lock status.
|
||||
- Tier 3 heightmap resolution (512×256 equirectangular at 1024×512 PNG) is the canonical format. Deviation requires amending D-191.
|
||||
- Tier 4 province boundaries are pre-computed at build-time and stored in `atlas_province_boundaries` (D-205). They are not re-computed at runtime.
|
||||
- The `SettingType` enum on `DistrictSkeleton` is the interface between Tier 5 (settlement planning) and Tier 6 (district generation).
|
||||
- **Rationale:** Locking spatial dimensions prevents the generative layers from drifting in incompatible directions. The heightmap pipeline, atlas pipeline, and district generator all assume these dimensions and would need coordinated migration if they changed. Formalization prevents silent per-system variation.
|
||||
- **Ticket:** #912 (WorldTier enum), #913 (SettlementClass)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-094 (district hierarchy — Tiers 6–8), D-191 (atlas pipeline — Tier 3), D-205 (province boundaries — Tier 4), D-208 (D8 drainage — Tier 3 analysis)
|
||||
|
||||
### D-202: Heightmap BLOB Storage Schema (atlas_body_heightmaps)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Heightmap elevation data is stored in `systems.db` as a BLOB in the `atlas_body_heightmaps` table. Schema:
|
||||
```sql
|
||||
CREATE TABLE atlas_body_heightmaps (
|
||||
body_id INTEGER PRIMARY KEY REFERENCES bodies(id),
|
||||
width INTEGER NOT NULL, -- pixel columns (canonical: 512)
|
||||
height INTEGER NOT NULL, -- pixel rows (canonical: 256)
|
||||
data BLOB NOT NULL, -- float32 little-endian, row-major, width×height floats
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
- `data` is a `float32` little-endian BLOB. Size: `width × height × 4` bytes. Canonical: 512×256×4 = ~512KB per body.
|
||||
- Values are normalized elevation in [0.0, 1.0]. `sea_level` is the fraction below which terrain is underwater (default 0.0 = no ocean, overridden per body).
|
||||
- The Rust loader reads the BLOB via `bytemuck::cast_slice::<u8, f32>()` after fetching from SQLite. No endian conversion needed on LE-native systems; the pipeline stores LE explicitly.
|
||||
- Only inhabited bodies receive heightmap rows at build-time. Uninhabited bodies are generated on-demand (runtime-background tier, D-200).
|
||||
- This table is populated by the `import_heightmaps` build-time step in the asset pipeline (D-191 §9 pipeline order). It is read-only at runtime.
|
||||
- **Rationale:** Storing heightmaps in `systems.db` keeps the DB as the single source of truth for all generation inputs, avoids a separate file-fetching path in the Rust server, and allows the pre-push hook (asset pipeline rules) to detect stale heightmap data. The float32 LE layout matches what NumPy and PIL produce natively, minimizing conversion overhead in the Python pipeline.
|
||||
- **Ticket:** #901 (schema), #906 (import), #916 (Rust loader)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline — heightmap source), D-203 (BodyWorldState — consumer), D-208 (D8 drainage — reads this table)
|
||||
|
||||
### D-203: BodyWorldState Bevy Resource with LRU Cache
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** `BodyWorldState` is a Bevy `Resource` holding the Layer 1–2 output for each recently-accessed planetary body. It functions as an LRU (Least Recently Used) cache:
|
||||
- **Cache capacity:** 50 bodies.
|
||||
- **Memory budget:** ~5MB total (50 bodies × ~100KB per entry average). A single body's Layer 1–2 data includes: processed heightmap (float32 grid, ~512KB pre-downsampled to ~8KB working resolution), river network (`RiverNetwork` struct: river cells, confluences, mouths), drainage basin polygons, attractor list, province boundary references.
|
||||
- **Eviction policy:** On cache overflow, evict the body with the oldest `last_accessed` timestamp. Bodies that are the current player location or adjacent-system neighbors are pinned (not evicted).
|
||||
- **Population:** The runtime-background tier (D-200) populates cache entries via Rayon tasks. Main thread reads are always from the cache; main thread code must never perform blocking DB reads for heightmap data.
|
||||
- **Struct:**
|
||||
```rust
|
||||
struct BodyWorldState {
|
||||
body_id: u64,
|
||||
heightmap: Vec<f32>, // downsampled working grid
|
||||
river_network: RiverNetwork, // D-208 output
|
||||
drainage_basins: Vec<DrainageBasin>,
|
||||
attractors: Vec<GeographicAttractor>, // D-195 types
|
||||
last_accessed: SimTick,
|
||||
}
|
||||
```
|
||||
- The resource is initialized empty and populated on demand. Accessing a body not in the cache triggers a background generation task (D-206).
|
||||
- **Rationale:** The D8 drainage analysis (D-208) and attractor extraction (D-209) are expensive (target: ~50ms/body). Running them on the main tick thread would cause frame drops. The LRU cache ensures the main thread only reads pre-computed data. 50-body capacity covers the typical gameplay scenario (player in one system, neighboring system pre-cached) with margin.
|
||||
- **Ticket:** #917
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-200 (three-tier execution model), D-202 (heightmap BLOB — input), D-206 (background generation queue — populates cache), D-208 (D8 drainage — produces river network)
|
||||
|
||||
### D-204: body_radius_km Column on bodies Table
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** A `body_radius_km REAL` column is added to the `bodies` table in `systems.db`. This value is the mean radius of the planetary body in kilometers, used to:
|
||||
- Compute `area_count` (number of districts a settlement can contain, scales with surface area)
|
||||
- Convert province boundary pixel coordinates to real-world km distances
|
||||
- Derive the `footprint_radius_km` field on `CityGenerationContext` (D-200)
|
||||
- Schema change: `ALTER TABLE bodies ADD COLUMN body_radius_km REAL` (nullable, populated by import step)
|
||||
- **Fallback derivation** (applied when `body_radius_km IS NULL`): `planet_class` lookup table with canonical radii:
|
||||
- `super_earth`: 8,000 km
|
||||
- `earth_like`: 6,371 km
|
||||
- `sub_earth`: 4,500 km
|
||||
- `ocean_world`: 6,500 km
|
||||
- `arid`: 5,800 km
|
||||
- `ice_world`: 3,000 km
|
||||
- `gas_giant`: 50,000 km (no settlements)
|
||||
- `moon`: 1,737 km
|
||||
- `other` / unknown: 6,371 km (Earth default)
|
||||
- Fallback is applied at query time, not stored back. The column remains NULL until authoritative data is available.
|
||||
- **Rationale:** Surface area scales with radius squared; a body twice Earth's radius has four times the potential settlement density. Without this field the generator must use a flat default for all planets, producing physically implausible city counts on super-earths and moons alike. The fallback ensures the generator works before all bodies have explicit radius data.
|
||||
- **Ticket:** #905 (schema), #910 (populate from planet_class fallback)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline — body catalog), D-200 (CityGenerationContext — footprint_radius_km)
|
||||
|
||||
### D-205: Province Boundary Pre-Computation (atlas_province_boundaries)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Province boundaries (drainage basin divides) are pre-computed at build time from the D8 drainage analysis (D-208) and stored in `atlas_province_boundaries`:
|
||||
```sql
|
||||
CREATE TABLE atlas_province_boundaries (
|
||||
body_id INTEGER NOT NULL REFERENCES bodies(id),
|
||||
basin_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL, -- JSON array [[row, col], ...] pixel-space polyline
|
||||
area_pct REAL NOT NULL, -- fraction of body surface area in this basin
|
||||
PRIMARY KEY (body_id, basin_id)
|
||||
);
|
||||
```
|
||||
- Boundaries are stored as pixel-space polylines in the same `[row, col]` convention as `markers.json` (D-191 §8 canonical format).
|
||||
- `area_pct` is the fraction of the body's total surface area contained within this drainage basin.
|
||||
- Province boundaries are the basis for district-level political zoning and cultural corridor assignment at Tier 4 (Region) in D-201.
|
||||
- **Province count target:** 4–12 provinces per inhabited body, derived naturally from watershed analysis. Bodies with less topographic relief (plains worlds, ocean worlds) produce fewer, larger provinces.
|
||||
- **At runtime:** Province boundaries are read from `atlas_province_boundaries` at generation dispatch time and cached in `BodyWorldState` as `drainage_basins` (D-203). They are not re-computed at runtime.
|
||||
- **Rationale:** Province boundaries define the cultural geography of a world — the mountain ranges and river systems that separated civilizations and produced distinct regional identities. Pre-computing them at build time keeps the runtime-background tier focused on city placement and district generation rather than watershed analysis. Storing as polylines (not rasterized masks) keeps the table compact and human-readable.
|
||||
- **Ticket:** #904 (schema), #907 (populate from watershed analysis)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline), D-201 (spatial hierarchy — Tier 4 Region), D-203 (BodyWorldState — caches province data), D-208 (D8 drainage — source of basin divides)
|
||||
|
||||
### D-206: Background Generation Priority Queue and Rayon Thread Infrastructure
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** All non-urgent generator work runs through a prioritized Rayon thread pool:
|
||||
- **Thread count:** `available_parallelism - 2`, minimum 1. Reserves 2 cores for the main tick thread and Bevy scheduler.
|
||||
- **Priority queue:** Four levels: `Immediate` (player will arrive within 1 game-minute), `High` (player will arrive within 5 minutes), `Medium` (player is in the same system), `Low` (player has seen or heard of this location via NPC or news). Work items at higher priority pre-empt lower-priority items.
|
||||
- **Work item types:** `AnalyzeBody(body_id)` (D8 drainage + attractor extraction), `GenerateSkeleton(city_id, context)` (Phase 1 DistrictSkeleton), `FillChunk(district_id, block_pos)` (Phase 2 chunk fill for pre-loading).
|
||||
- **Event-driven pre-generation:** A `SystemNameIndex` (Aho-Corasick automaton over all body/system names from `systems.db`) scans NPC dialogue output and news ticker text. When a scan match hits, the referenced body is queued at `Low` priority if not already cached. This is the mechanism by which "NPC mentions a place → player travels there → world is already generated on arrival."
|
||||
- **Completion notification:** Completed tasks send a `GenerationComplete` event to the main tick thread via a `crossbeam` channel. The main thread drains this channel once per tick.
|
||||
- **Rationale:** The Rayon thread pool handles the D-200 runtime-background tier. The priority queue prevents low-priority speculation from blocking urgent work (player approaching). The Aho-Corasick name index enables cheap always-on scanning — NPC dialogue is low-bandwidth enough that scanning every output line has negligible cost. Pre-generation triggered by narrative content (NPC mentions a place) is the mechanism for making the world feel pre-existing rather than loading-on-demand.
|
||||
- **Ticket:** #924 (background queue), #926 (SystemNameIndex)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-200 (three-tier execution model — runtime-background tier), D-203 (BodyWorldState — output of background tasks), D-208 (D8 drainage — enqueued as AnalyzeBody)
|
||||
|
||||
### D-207: Fully Generative Placement — markers.json Stripped to Topographic Features
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** The `atlas_city_names` table replaces the authored city positions in `markers.json`. Going forward, `markers.json` files contain only topographic features (rivers, oceans, mountain ranges — per D-191 §8 canonical format). City positions, road networks, and rail networks are NOT authored in `markers.json`; they are generated from the terrain data and stored in `atlas_city_names` and derived tables.
|
||||
```sql
|
||||
CREATE TABLE atlas_city_names (
|
||||
id INTEGER PRIMARY KEY,
|
||||
body_id INTEGER NOT NULL REFERENCES bodies(id),
|
||||
name TEXT NOT NULL,
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
corp_id INTEGER REFERENCES corporations(id), -- nullable, corp HQ if applicable
|
||||
reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use
|
||||
kind TEXT NOT NULL DEFAULT 'city' -- 'capital' | 'city'
|
||||
);
|
||||
```
|
||||
- `name` and position in the markers.json come from different sources: position is generated by the city placement algorithm; name is either authored (wiki), LLM-generated (Gemma 2 naming pipeline), or reserved (corp HQ name). The split allows position generation and naming to run independently.
|
||||
- `corp_id` links to the `corporations` table when a city is a corporation's headquarters or major hub city.
|
||||
- `reserved = 1` rows are scenario-specific cities that must not be relocated by the generation algorithm; the generator places other cities around them.
|
||||
- **`markers.json` authored city data** (hand-written city center positions in the 6 hand-authored templates: Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade) is migrated to `atlas_city_names` and treated as `reserved = 1` rows. The markers.json files for these templates then have their city arrays cleared.
|
||||
- **Rationale:** Authored city positions in markers.json created a split between hand-authored content and procedurally generated content that was impossible to query, diff, or validate consistently. Moving city identity to a table allows: SQL joins against economic data, corp HQ cross-references, scenario reservations, and attractor-matching validation. The topographic features (rivers, mountains) remain in JSON because they are polygon/polyline geometry better suited to JSON than relational rows.
|
||||
- **Ticket:** #902 (schema), #908 (populate from wiki), #909 (corp HQ cross-reference)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas pipeline — markers.json canonical format), D-196 (SettlementClass — column in atlas_city_names), D-200 (CityGenerationContext — reads from this table)
|
||||
|
||||
### D-208: D8 Priority-Flood Drainage Routing — Layer 1 Empty World
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Drainage routing (the computation of flow direction, flow accumulation, and river network extraction) uses the **D8 priority-flood** algorithm on the body's float32 heightmap. This is the first Layer 1 computation run on a body before any city placement or attractor extraction.
|
||||
- **Algorithm:** D8 assigns each cell's flow direction to one of 8 neighbors based on the steepest descent. Priority-flood fills depression cells before routing to avoid spurious sinks. Flow accumulation is the count of upstream cells draining through each cell.
|
||||
- **River threshold:** A cell is classified as a river cell when `flow_accumulation > 200`. This threshold produces river networks of realistic density on canonical 512×256 heightmaps.
|
||||
- **Outputs** stored in `BodyWorldState.river_network`:
|
||||
- `river_cells: Vec<(u16, u16)>` — pixel positions of all river cells
|
||||
- `confluences: Vec<(u16, u16)>` — positions where two or more rivers merge
|
||||
- `mouths: Vec<(u16, u16)>` — positions where rivers reach sea level or the heightmap edge
|
||||
- **Province/basin output:** Cells that divide adjacent drainage basins become province boundary candidates (D-205). Boundaries are traced as polylines after flow accumulation is complete.
|
||||
- **Performance target:** ~50ms per body on a single Rayon thread for canonical 512×256 resolution.
|
||||
- **Determinism:** Integer-only arithmetic throughout. No f32 in the priority-flood comparisons (use integer-scaled elevation). D-010 compliant.
|
||||
- **Rationale:** D8 is the standard GIS drainage routing algorithm and produces the river networks that drive attractor scoring (river mouths, confluences = high-value `RiverMouth` attractors). The flow accumulation threshold of 200 was chosen empirically against the Lendel heightmap to produce ~8–15 named rivers per inhabited body — enough for cultural geography without over-fragmenting the landscape.
|
||||
- **Ticket:** #918
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-195 (attractor types — river mouth is highest-scoring), D-203 (BodyWorldState — output stored here), D-205 (province boundaries — derived from drainage divides), D-209 (feature tag extraction — reads river network)
|
||||
|
||||
### D-209: Geographic Feature Tag Extraction (7 Settlement Attractor Tags)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** After D8 drainage analysis, 7 `AttractorType` tags are extracted from the heightmap + river network and stored as `Vec<GeographicAttractor>` in `BodyWorldState`. Each attractor has a position `[row, col]` and a `strength: f32` (0.0–1.0) derived from local terrain quality.
|
||||
- **Extraction rules per type:**
|
||||
- `RiverMouth`: cells in `river_network.mouths`. Strength = `flow_accumulation[cell] / max_flow_accumulation` (normalized). Always high-value.
|
||||
- `CoastalAccess`: cells within 3 pixels of a sea/ocean polygon (from `oceans[]` in markers.json), not already `RiverMouth`. Strength = 0.6 baseline + coast length bonus.
|
||||
- `RiverCrossing`: cells at confluences or where a river crosses a topographic saddle. Strength = `flow_accumulation / max_flow_accumulation × 0.7`.
|
||||
- `ValleyFloor`: local elevation minima in non-river cells with positive habitability score (slope < 5°, elevation 10–60% of range). Strength = habitability score.
|
||||
- `PassEntrance`: local saddle points between adjacent drainage basins. Strength = inverse of elevation percentile (lower passes score higher).
|
||||
- `LakeShore`: cells adjacent to `lake` polygons in markers.json. Strength = 0.5 baseline.
|
||||
- `PlainCenter`: cells in flat terrain (slope < 2°) away from all other attractors. Strength = habitability score × 0.4.
|
||||
- Sub-biome classification (vegetation, aridity, temperature zones) is derived in parallel and stored as `SubBiomeVariant` on the attractor for use by the ZonePalette modifier system (D-101).
|
||||
- **Rationale:** The 7 attractor types cover the terrain features that historically determine city placement. Their extraction from the heightmap is deterministic and cheap given the D8 analysis is already complete. The strength normalization ensures attractor scores are comparable across bodies with different elevation ranges.
|
||||
- **Ticket:** #925 (types), #919 (matching pipeline that consumes these)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-195 (CompatibilityMatrix — attractor types), D-203 (BodyWorldState — storage), D-208 (D8 drainage — prerequisite), D-211 (attractor-matching pipeline — consumer)
|
||||
|
||||
### D-210: Sub-Biome Variant Classification and terrain_modification_cost
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Each `GeographicAttractor` (D-209) carries a `sub_biome: SubBiomeVariant` tag that classifies the local terrain more finely than the top-level `SettingType`. This drives two systems: ZonePalette modifier selection (which visual variant to use) and `terrain_modification_cost` (how expensive it is to build infrastructure at this location).
|
||||
- `SubBiomeVariant` values: `TropicalWet`, `TemperateForest`, `TemperateGrassland`, `BorealForest`, `Tundra`, `Desert`, `Savanna`, `Alpine`, `Wetland`, `CoastalLowland`, `Volcanic`.
|
||||
- `terrain_modification_cost: f32` (1.0 = baseline, higher = more expensive): derived from sub-biome + local slope. Flat grassland = 1.0. Volcanic = 4.5. Wetland = 3.2. Alpine = 3.8. Coastal lowland = 1.4. Used by the attractor-matching pipeline (D-211) to penalize high-cost terrain for economically marginal cities.
|
||||
- Sub-biome classification uses: elevation percentile (of body total), local slope, moisture proxy (distance to nearest river mouth or coast), and temperature proxy (latitude of the equirectangular pixel).
|
||||
- Sub-biome data is stored in `BodyWorldState` alongside the attractors; it is not a separate DB table.
|
||||
- **Rationale:** Two cities on coastal terrain feel different when one is a tropical lowland port and the other is a cold Nordic fjord. Sub-biome tags enable the ZonePalette to select the correct visual register (T6 beach/coastal with tropical modifier vs T7 mountain/high with coastal modifier). The `terrain_modification_cost` gives the generator a principled reason to prefer some attractor positions over others for lower-prosperity cities.
|
||||
- **Ticket:** #919 (attractor matching — uses terrain_modification_cost)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-101 (ZonePalette modifier system — consumes sub_biome), D-195 (attractor types), D-209 (feature tag extraction — assigns sub_biome)
|
||||
|
||||
### D-211: Attractor-Matching Five-Phase Pipeline for Settlement Placement
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Given a body's `Vec<GeographicAttractor>` and a set of cities from `atlas_city_names`, settlement placement runs a five-phase matching pipeline:
|
||||
1. **Score matrix build:** Compute a `city_count × attractor_count` score matrix. Each cell = `CompatibilityMatrix[economic_role][attractor_type] × attractor.strength × (1.0 / terrain_modification_cost)`.
|
||||
2. **Tier A greedy assignment:** For each city with `SettlementClass::NameLocked` or population ≥ 1,000,000, assign the highest-scoring unoccupied attractor using greedy selection. These cities must be placed first to anchor the spatial layout.
|
||||
3. **Hungarian algorithm for Tier B+C:** Apply the Hungarian algorithm to the remaining cities (population 50,000–999,999) and remaining attractors. Produces optimal global assignment maximizing total score.
|
||||
4. **Synthetic attractor overflow:** Cities that cannot be matched to a real attractor (attractor pool exhausted) receive a synthetic `PlainCenter` attractor generated at a position that respects minimum city spacing (15 pixels minimum on 512×256 grid = ~50km minimum separation).
|
||||
5. **Name fulfillment check:** After placement, verify that all `atlas_city_names` entries for this body have been assigned a position. Log a warning for any unplaced city.
|
||||
- **Two-tier mismatch flagging:** If a matched city-attractor pair has score < 0.35, log a `WARNING` (below expected quality). If score < 0.15, log an `ERROR` and flag for manual review. Generation proceeds in both cases; the flags are for content auditing, not hard blockers.
|
||||
- **Output:** `Vec<CityPlacement { city_id, position: [row, col], attractor: AttractorType, score: f32 }>` written to `atlas_city_positions` at build time.
|
||||
- **Rationale:** Greedy-first for large/locked cities ensures anchor cities (capitals, corp HQs, wiki-named cities) are placed at terrain features that match their lore role. Hungarian for medium cities finds the globally optimal assignment, not just locally optimal. Synthetic overflow prevents the algorithm from failing on bodies where city count exceeds natural attractor count (dense, flat worlds). The two-tier warning system enables content QA without blocking generation.
|
||||
- **Ticket:** #919, #925
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-195 (CompatibilityMatrix), D-207 (atlas_city_names), D-209 (GeographicAttractor — input), D-210 (terrain_modification_cost — input)
|
||||
|
||||
### D-212: TerritorialStatus Priority-Ordered Derivation Algorithm
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Each `Province` (watershed-derived drainage basin, D-205) receives a `TerritorialStatus` value derived by priority-ordered classification at generation time:
|
||||
```rust
|
||||
enum TerritorialStatus {
|
||||
CommissionControlled, // Commission faction_influence ≥ 0.6 in this province
|
||||
CorpTerritory, // Single corporation faction_influence ≥ 0.5
|
||||
ContestedZone, // Two or more factions each ≥ 0.3, no dominant faction
|
||||
FrontierUnclaimed, // No faction with influence ≥ 0.2
|
||||
IndigenousHeld, // Cultural corridor has indigenous autonomy flag
|
||||
Derelict, // population_density < 0.01 AND no faction ≥ 0.1
|
||||
}
|
||||
```
|
||||
- Classification applies checks in priority order: `CommissionControlled` checked first, `Derelict` last. The first condition that is true sets the status.
|
||||
- `placed_at_generation: bool` flag on `Province` distinguishes classification at build time (true) from runtime re-classification during simulation (false). Build-time status is the starting state; simulation can change it, and the flag ensures the original classification is recoverable for reset/new-game scenarios.
|
||||
- Faction influence values are read from `systems.db` (economics tables) at build time using the same D-199 economic read pattern.
|
||||
- **Rationale:** Territory status is a high-level descriptor visible to the player on the Atlas overlay (D-191 §7, political zones overlay). It must be derivable from the generation inputs without runtime simulation state. The priority-ordered algorithm ensures clear, predictable classification — no ambiguous provinces. The `placed_at_generation` flag enables the game to show "how this province was at settlement time" vs. "how it is now."
|
||||
- **Ticket:** #921
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-191 (atlas overlay — political zones), D-199 (economic read set), D-205 (Province — this status is a field on it)
|
||||
|
||||
### D-213: FoundingOrientation Enum and Spatial Grid Rotation
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** `FoundingOrientation` describes the primary spatial axis of a city's original street grid, derived from the terrain feature that anchored the founding settlement. It controls the rotation of the district grid skeleton.
|
||||
```rust
|
||||
enum FoundingOrientation {
|
||||
Coastal { facing_degrees: u16 }, // street grid perpendicular to coastline
|
||||
RiverAligned { bearing_degrees: u16 }, // street grid parallel to founding river
|
||||
TerrainFollowing, // grid rotated to follow local contours
|
||||
Cardinal, // grid aligned to N/S/E/W (commission-planned)
|
||||
Free { bearing_degrees: u16 }, // arbitrary bearing (pioneer settlements)
|
||||
}
|
||||
```
|
||||
- `facing_degrees` and `bearing_degrees` are integer degrees 0–359 (0 = North, clockwise). Integer to preserve D-010 determinism.
|
||||
- The founding orientation is derived from the matched attractor type (D-211): `RiverMouth` → `Coastal`; `RiverAligned`; `CoastalAccess` → `Coastal`; `ValleyFloor` → `TerrainFollowing`; `PlainCenter` + Commission-controlled province → `Cardinal`; `PlainCenter` + other → `Free`.
|
||||
- The district skeleton generator (Phase 1) applies `FoundingOrientation` as the base rotation for the outermost district ring. Interior districts inherit the orientation unless overridden by a `PoliticalArchetype` modifier.
|
||||
- **Hard constraint:** Maximum ±45° deviation from the parent orientation per district (same limit as D-096 `BlockPlacement.rotation_steps`). Beyond ±45°, tile-based pathfinding produces movement artifacts.
|
||||
- **Rationale:** Street grids reflect the terrain and founding logic of the original settlement. Roman camps faced cardinal directions. River towns align with the river. Coastal cities face the water. Encoding this as a named enum rather than a raw angle makes the orientation legible in the data model and debuggable during generation.
|
||||
- **Ticket:** #914
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-096 (DistrictLayoutMode — inherits orientation), D-211 (attractor-matching — derives orientation), D-214 (PoliticalArchetype — may override orientation), D-215 (spatial arrangement patterns — uses orientation)
|
||||
|
||||
### D-214: PoliticalArchetype Enum and Settlement Spatial Character
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** `PoliticalArchetype` classifies a settlement's dominant power structure and its physical expression in district layout:
|
||||
```rust
|
||||
enum PoliticalArchetype {
|
||||
Commission, // Top-down Commission planning; rectilinear, institutional core
|
||||
Corporate, // Corp-dominated; commercial density, restricted zones, campus blocks
|
||||
Pioneer, // Self-organized; organic growth, mixed use, ad-hoc infrastructure
|
||||
Military, // Garrison or fortification origin; defensible geometry, restricted perimeter
|
||||
Academic, // University or research origin; campus-quad structure, green space
|
||||
Industrial, // Factory-first; large-footprint industrial blocks, worker residential rings
|
||||
}
|
||||
```
|
||||
- `PoliticalArchetype` is derived at generation time from `TerritorialStatus` (D-212) + `economic_role`: `CommissionControlled` province → `Commission`; `CorpTerritory` → `Corporate`; `FrontierUnclaimed` → `Pioneer`; military economic role → `Military`; research economic role → `Academic`; manufacturing + extraction → `Industrial`.
|
||||
- When multiple signals conflict (e.g., Commission-controlled manufacturing hub), `TerritorialStatus` takes precedence over `economic_role` for archetype derivation.
|
||||
- **Spatial effect on district mix:** See D-194. Each archetype applies weight multipliers to district type selection.
|
||||
- **`AttractorAssignment` disambiguation:** `OrganicGrowth` (a `DistrictType` value and also an `EraCause` value) is always unambiguous in context. On `DistrictType`, it means the district grew without a planning mandate. As `EraCause`, it means the era tag was acquired through organic settlement expansion rather than a discrete historical event. Both usages are permitted; the type system distinguishes them.
|
||||
- **Rationale:** Power structure should be legible in a city's spatial form without the player reading a wiki entry. Commission cities look different from Corporate cities look different from Pioneer cities — not just in palette, but in street geometry, district type distribution, and building scale. Encoding this as a named enum ensures the distinction is consistent across all generation code.
|
||||
- **Ticket:** #914
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-194 (district mix — archetype modifiers), D-212 (TerritorialStatus — primary input), D-213 (FoundingOrientation — archetype may override), D-215 (spatial arrangement patterns)
|
||||
|
||||
### D-215: Five Explicit Political Archetype Spatial Arrangement Patterns
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** Each `PoliticalArchetype` maps to one of five spatial arrangement patterns that govern district adjacency and the placement of landmark multi-block reservations:
|
||||
1. **Radial core** (Commission, Academic): Central landmark (civic square, institutional plaza, or university quad) surrounded by mixed-use rings. Transit spokes radiate outward. Districts are denser near center.
|
||||
2. **Campus grid** (Corporate): Restricted campus block occupies 2–4 blocks in the district interior. Commercial districts ring the exterior. Worker residential on periphery.
|
||||
3. **Ribbon development** (Pioneer, Industrial): Districts string along a linear feature (river, road, industrial rail). No dominant center. Mixed adjacency at every edge.
|
||||
4. **Fortified perimeter** (Military): Restricted and Secured districts at the edge of the footprint. Open access in the interior core. Single controlled access point per district edge.
|
||||
5. **Hub-and-spoke** (transit_hub economic role, any archetype): Transit district at center, all other district types accessible via direct corridors. Maximum 2-district travel between any two districts.
|
||||
- The arrangement pattern constrains block adjacency during Phase 1 skeleton generation. Specifically: the first 2–3 districts placed in a settlement follow the pattern. Later districts are constrained only by the road network, not by the pattern.
|
||||
- Arrangement patterns must **vary in angular orientation** per seed (not just position) — the same archetype's radial core must not always face the same direction across seeds.
|
||||
- **Rationale:** The 14 D-ready items from the generator-architecture workshop established that spatial arrangement should encode power structure. These five patterns are the minimal set to cover the 6 archetypes (Pioneer and Industrial share ribbon development; hub-and-spoke is a cross-archetype pattern for transit-primary cities). Pattern variation in angular orientation prevents players from pattern-matching settlement layout after the first playthrough.
|
||||
- **Ticket:** #914 (types), #899 (implementation — Phase 1 skeleton generator)
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-094 (spatial hierarchy — district sizes), D-194 (district mix — archetype modifiers), D-214 (PoliticalArchetype — pattern assignment)
|
||||
|
||||
### D-216: BlockIrregularity from founding_age — Layout Age Character
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** `block_irregularity: f32` is a derived value on each block (range 0.0–1.0) that controls how much a block deviates from the district's canonical grid. It is computed from `founding_age_years` and `PoliticalArchetype`. The formula:
|
||||
```
|
||||
base_irregularity = (founding_age_years / 1000.0).min(1.0)
|
||||
archetype_step = match archetype {
|
||||
Commission | Military => -0.3, // suppresses organic deviation
|
||||
Corporate | Academic => -0.1,
|
||||
Industrial => 0.0,
|
||||
Pioneer => +0.3,
|
||||
}
|
||||
block_irregularity = (base_irregularity + archetype_step).max(0.05).min(1.0)
|
||||
```
|
||||
- Minimum 0.05 is enforced — no block is perfectly regular, even new Commission-planned settlements.
|
||||
- `block_irregularity` feeds the `BlockPlacement.offset` magnitude in `DistrictLayoutMode::Organic`: `max_offset_sim_tiles = (block_irregularity × 16.0) as i16`.
|
||||
- An old Pioneer settlement (age 800+ years) can have `block_irregularity ≈ 1.0`, producing maximum ±16 sim tile offsets and ±45° rotations. A new Commission district (age < 50 years) will have `block_irregularity ≈ 0.05`.
|
||||
- All arithmetic uses integer-scaled intermediates wherever possible (age is integer years; archetype_step is stored as integer basis points internally). The f32 in the formula above is for documentation clarity only.
|
||||
- **Rationale:** Age is the single most reliable predictor of urban irregularity in the real world. Old cities that grew organically have crooked streets; new planned cities have grids. Encoding this as a formula rather than a lookup table allows continuous variation along the age axis while preserving the political meaning of the archetype modifier.
|
||||
- **Ticket:** #922
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-096 (DistrictLayoutMode::Organic — consumes block_irregularity), D-194 (district mix — founding_age is an input), D-214 (PoliticalArchetype — archetype_step source)
|
||||
|
||||
### D-217: Tile Condition Thresholds (0.63 / 0.43 / 0.23)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** A tile's visual condition is derived from the district's `prosperity_score` (live pressure simulation value, 0.0–1.0) using four threshold bands:
|
||||
| Band | Condition | prosperity_score range | Tile visual state |
|
||||
|------|-----------|----------------------|-------------------|
|
||||
| 1 | Intact | > 0.63 | Clean, undamaged, well-maintained |
|
||||
| 2 | Worn | 0.43 – 0.63 | Scuff marks, minor discoloration, partial repairs |
|
||||
| 3 | Cracked | 0.23 – 0.43 | Visible damage, incomplete repair, graffiti |
|
||||
| 4 | Broken | < 0.23 | Structural damage, debris, derelict appearance |
|
||||
- **Cache invalidation:** A tile's condition only changes when `prosperity_score` crosses a threshold boundary (from band N to band N±1). This avoids per-tick visual updates. The simulation checks threshold crossings once per game-minute (D-031 day-phase tick rate).
|
||||
- **Baseline floor:** The block's `EraCause` sets a minimum condition floor:
|
||||
- `Decay` era: minimum Cracked (no tile in a Decay-era block is ever Intact or Worn without an active renovation event)
|
||||
- `EmergencyExtension` era: minimum Worn
|
||||
- All other eras: no floor (condition follows prosperity_score freely)
|
||||
- **Phase 2 application:** Chunk fill applies the baseline condition at fill time. Subsequent condition updates from simulation crossing thresholds are applied as `ChunkMutations.tile_overrides`.
|
||||
- Condition thresholds are authored constants, not computed. Any change to the thresholds (0.63 / 0.43 / 0.23) requires amending this D-record.
|
||||
- **Rationale:** Threshold-crossing invalidation is a standard visual LOD technique that avoids expensive per-frame recalculation. The four bands (Intact/Worn/Cracked/Broken) match the visual fidelity budget for the current art direction — more bands require more tile variants per palette. The era-based floor ensures that historical context is always visible: a Decay-era block cannot spontaneously look pristine from a prosperity spike alone.
|
||||
- **Ticket:** #923
|
||||
- **Raised by:** Generation cascade workshop (#897)
|
||||
- **Cross-reference:** D-100 (DamageOverlay — post-condition modification), D-194 (district mix — prosperity_baseline is the seed for prosperity_score)
|
||||
|
||||
### D-218: WorldTier Enum Canonical Values (Epicenter/Regional/Backwater/Passage/Waypoint)
|
||||
- **Date:** 2026-05-01
|
||||
- **Decision:** The canonical `WorldTier` enum values are:
|
||||
```rust
|
||||
enum WorldTier {
|
||||
Epicenter, // Hub system. Full simulation. High faction pressure. Multi-district cities.
|
||||
Regional, // Regional hub. 1–4 districts per city. Partial full-budget districts.
|
||||
Backwater, // Small community. Dense isolated settlement. Full sim budget — NOT capped.
|
||||
Passage, // Transit stop. Pass-through. ComplexityTier ceiling: Moderate.
|
||||
Waypoint, // Not simulated until player approaches. ComplexityTier ceiling: Minimal.
|
||||
}
|
||||
```
|
||||
- The values `Peripheral`, `Connected`, and `Core` used in generator.rs prior to Sprint 38 are **incorrect** — they were stubbed values that do not match the workshop design (workshop-outcomes.md §WorldTier and ComplexityTier). They must be replaced with the five canonical values above.
|
||||
- **ComplexityTier ceiling per WorldTier:**
|
||||
- `Epicenter` → Full
|
||||
- `Regional` → Full
|
||||
- `Backwater` → Full (critical: `Backwater` is network-insignificant, NOT budget-capped; isolated communities can be socially complex)
|
||||
- `Passage` → Moderate
|
||||
- `Waypoint` → Minimal
|
||||
- **Source of truth:** workshop-outcomes.md §WorldTier and ComplexityTier table (generator-architecture workshop, lead decision L-3).
|
||||
- All code referencing `WorldTier::Peripheral`, `WorldTier::Connected`, or `WorldTier::Core` must be updated to the canonical values. This includes generator.rs, any tests, and any serialized data that references these variants.
|
||||
- **Rationale:** The three-value stub (Peripheral/Connected/Core) was authored before the generator architecture workshop established the five-value canonical model. The mismatch between the code and the design means any generator code built against the stub types would need rewriting anyway. Correcting it now before the Phase 1 implementation work begins eliminates that rework. The `Backwater` full-budget exception is architecturally significant: dense isolated communities (mining towns, research outposts) should be as socially rich as regional hubs — their isolation is their drama, not their limitation.
|
||||
- **Ticket:** #900 (bug fix), #912 (full enum implementation)
|
||||
- **Raised by:** Generation cascade workshop (#897). Original workshop-outcomes.md §WorldTier (generator-architecture workshop, lead decision L-3).
|
||||
- **Cross-reference:** D-096 (DistrictLayoutMode — WorldTier is a DistrictSkeleton field), D-097 (GuaranteeAuditResult — tier-conditional guarantees), D-201 (spatial hierarchy — WorldTier assigned at Tier 2 System level)
|
||||
|
||||
---
|
||||
|
||||
*79 decisions (D-001 through D-218, excluding gaps). Last updated: 2026-05-02 (D-218 — WorldTier canonical values, generation cascade workshop Sprint 38)*
|
||||
|
||||
+15
-4
@@ -44,9 +44,10 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
- **Dissent:** None
|
||||
- **Amendment (2026-03-05, Where's the Fun? Workshop):** The 30/50/20 population split rationale survives but implementation context changes. [D-122](#d-122-all-npcs-generated--no-named-hand-authored-characters) (all NPCs generated) means no NPC is hand-authored. The "entangled 20%" are generated NPCs whose triangles happen to be flagged for intrigue content. For the tycoon v0.2 bookmark ([D-117](scope.md#d-117-tycoon-is-the-v02-bookmark--zero-investigation-content)), the split applies to economic, social, and mundane triangles rather than investigation-intrigue triangles. The specific ratios will be revisited after the generator spike ([D-119](scope.md#d-119-generator-spike-confirmed-for-sprint-25--critical-path)) proves what population density the generator can sustain.
|
||||
|
||||
### D-032: Separate monologue pools per character [SUPERSEDED]
|
||||
### D-032: Separate monologue pools per character [SUPERSEDED — deferred to Phase 6]
|
||||
- **Date:** 2026-02-11
|
||||
- **Superseded by:** [D-117](scope.md#d-117-tycoon-is-the-v02-bookmark--zero-investigation-content) (single tycoon character in v0.2 eliminates the smuggler/detective hard partition). The principle of character-specific monologue pools survives — the tycoon has their own monologue pool. The hard partition between smuggler and detective does not apply when there is only one playable character. Per [D-127](#d-127-player-choices-are-the-content--rimworld-model-job-as-rails), the player's monologue reflects their character background. The partition design is preserved as a pattern for when multiple playable characters are reintroduced.
|
||||
- **Superseded by:** Development cascade (CLAUDE.md) — character/NPC monologue content is Phase 6 detail-coloring, below the current Phase 1 (wiki content). The smuggler/detective archetype enum and its hard-partitioned monologue pools were pre-cascade scaffolding and have been fully stripped from the server codebase (Sprint 37, #878, PR #137). The original D-117 supersession framing (single tycoon character in v0.2) is itself obsolete now that v0.2 is dropped (CLAUDE.md: "v0.2 target is dropped"). The partition *design pattern* is preserved in this record for when culture-driven / generator-produced monologue is reintroduced in Phase 6, but no corresponding code or content exists today.
|
||||
- **Amendment (2026-04-22, Sprint 37, PR #137):** Monologue pool selection is now unkeyed by archetype until a Phase 6 character system exists. `MonologueState.character` field and `CharacterArchetype` enum deleted from `server/src/bridge/types.rs`, `server/src/simulation/monologue.rs`, observer relabeling logic, and `server/content/modules/tier1/smuggling_ring_v0_1.yaml`. Uniform single-pool behavior is the intended end state pre-Phase-6 — not scaffolding deferred in place with a stub, but retired pending the real character system. Lead override recorded in `docs/architecture/sprint-37-878-audit.md` (2026-04-21 override section). Reintroduction gate: a confirmed Phase 6 character-model design is a prerequisite before this decision is revived.
|
||||
- **Decision:** Internal monologue content is hard-partitioned by playable character. The smuggler and detective have completely separate monologue pools — no shared lines. The `character` tag on monologue lines is a hard partition, not a filter. File structure uses separate files per character per location (e.g., `monologue-smuggler.yaml`, `monologue-detective.yaml`).
|
||||
- **Rationale:** Shared monologue would dilute character voice and undermine the dual-lens experience. Each character's internal voice must be independently coherent. Same trigger, different pool — this is how mirror moments work without either pool knowing about the other.
|
||||
- **Cross-reference:** Dialogue lines remain character-agnostic — the access tier system (D-028 Layer 1) handles per-character filtering without separate pools.
|
||||
@@ -80,7 +81,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
- `mood` (list\<enum\>): 8 moods for v0.1 — D-028 Layer 4 weighted selection
|
||||
- `tags` (list\<string\>): freeform escape hatch for author intent
|
||||
- **Monologue-specific additions:**
|
||||
- `character` (enum): `smuggler`, `detective` — hard partition per D-032 **[Obsolete post-D-117: smuggler/detective eliminated. v0.2 uses culture-driven voice per D-121; this enum is unused.]**
|
||||
- `character` (enum): historically `smuggler`, `detective` — removed. Per [D-032 SUPERSEDED] and the development cascade (CLAUDE.md), character-partitioned monologue is Phase 6 and has been stripped from the codebase (Sprint 37, #878). Field is unused; do not reintroduce without a confirmed Phase 6 design.
|
||||
- `trigger` (enum): 9 trigger types (enter_location, observe_npc, hear_sound, observe_anomaly, post_conversation, discover_evidence, witness_interaction, time_idle, return_visit)
|
||||
- `prerequisite` (map or null): knowledge state gate
|
||||
- **Authoring-only tags (not consumed by engine):** `dual_lens` (map, per-character notes), `notes` (string)
|
||||
@@ -92,6 +93,7 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
- **Amendment (Sprint 14):** Mood vocabulary renamed to match voice guide (monologue-voice-guide.md). Old → new: `fond`→`warm`, `comfortable`→`content`, `worried`→`anxious`, `concerned`→`frustrated`. Dropped: `analytical` (merged into `focused`), `conflicted` (modeled as `suspicious`+`warm` collision). Added: `hostile`. Final 8 moods: `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `relieved`, `focused`. Neutral = untagged.
|
||||
- **Amendment (Sprint 24):** `triangle_activated` added as 15th situation (fires post-TriangleActivated when player observes anchor NPCs). Two freeform tags registered as conventions: `triangle-signal` (line is part of the triangle activation sequence) and `tell-observation` (line observes a behavioral tell without naming its cause). `npc_in_los` prerequisite added for LOS-gated monologue lines. Schema updated to match.
|
||||
- **Amendment (Sprint 15):** Line ID namespace changed from location-scoped to NPC-scoped. Old scheme: `{location_slug}_{d|m}_{###}` (e.g., `the-terminal_d_039`) — all NPCs at a location share one ID sequence, requiring cross-file coordination and causing collisions at scale. New scheme: `{npc-slug}_{d|m}_{###}` for dialogue, `{npc-slug}_m_{s|d}_{###}` for monologue (e.g., `kael-davan_d_001`, `dock-worker_d_001`). Each NPC's IDs are independent — no cross-file coordination needed. Auto-generated NPCs use their generated slug. Schema regex patterns unchanged (prefix is still `^[a-z][a-z0-9-]*`), only the `description` field and convention documentation update. Migration: mechanical rename of all existing line IDs across ~20 dialogue files and monologue pools.
|
||||
- **Amendment (2026-04-22, Sprint 37, PR #137):** The `character` monologue-specific tag (historically `smuggler | detective`) is retired pending a Phase 6 character-model design — not deferred in place with a stub enum. Per the development cascade (CLAUDE.md), character-partitioned monologue is Phase 6 detail-coloring; the codebase has been stripped of the `CharacterArchetype` enum and the `MonologueState.character` field that keyed pool selection (#878). Pool selection is now archetype-independent. Authoring files that carry historical `character:` tags are content artifacts and will be re-evaluated when the Phase 6 character system is designed; engine consumption of the field is gone. Reintroduction gate: a confirmed Phase 6 character-model design is a prerequisite. See `docs/architecture/sprint-37-878-audit.md` (lead override section) for the cascade rationale.
|
||||
|
||||
### D-036: Sova Transit District / Van Maanen's Star as v0.1 setting
|
||||
- **Date:** 2026-02-11
|
||||
@@ -605,6 +607,15 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
- **Raised by:** Miri (Sprint 31, ticket #773)
|
||||
- **Dissent:** None.
|
||||
|
||||
### D-193: Lattice Commission — canonical long-form of the Commission
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** The Concord Assembly's regulatory authority is formally named **"the Lattice Commission"**. In-prose short form remains "the Commission" (unambiguous in context). Drift forms that appeared in earlier copy reviews — "Concord Commission" and "Assembly Commission" — are **non-canonical** and must be corrected wherever they appear. The long-form is required for institutional POI signage, tractus fee legal text, wiki page headers, and any formal/legal context.
|
||||
- **Rationale:** The Commission's regulated domain — implant hardware, medical-grade replacements, pharmaceutical production, and safety-critical manufactured goods that feed the Lattice — is what authorizes and scopes its jurisdiction. Naming it after the regulated domain (rather than the parent Assembly) mirrors how real-world regulatory bodies are often named by what they regulate (FDA, FAA, NRC) rather than by their chartering body. It also disambiguates from "Concord" which otherwise appears in the Concord Assembly itself, the Concord's member systems, etc. The Commission has no authority in Compact member systems — its identity is Lattice-scoped, not Concord-scoped.
|
||||
- **Affects:** Wiki content (all `wiki/factions/`, `wiki/technology/`, `wiki/corporations/` pages that reference the Commission), marker POI naming (e.g. `"Lattice Commission — Sirius Office"`), formal legal text in tractus fee documentation, institutional signage, institution templates in `wiki/_templates/`, dialogue pools, copy guides.
|
||||
- **Resolves:** [Q-095](questions-content.md#q-095-commission-formal-name--authoritative-designation)
|
||||
- **Raised by:** Copy review (Sprint 35, PR #127); resolved by Jeroen on 2026-04-21
|
||||
- **Dissent:** None.
|
||||
|
||||
---
|
||||
|
||||
*46 decisions. Last updated: 2026-04-05 (D-168 Iserlohn IP evaluation filed)*
|
||||
*47 decisions. Last updated: 2026-04-21 (D-193 Lattice Commission name resolved)*
|
||||
|
||||
@@ -225,10 +225,11 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
- `GameState.insert_active` is the source of truth (defaults true in v0.1; wired from snapshot field `insert_active`).
|
||||
- Rationale: diegetically consistent — the body reacts to proximity; the insert reacts to commands.
|
||||
|
||||
### D-057: Entity interaction — vertical list, insert-styled
|
||||
### D-057: Entity interaction — vertical list, insert-styled [PARTIALLY SUPERSEDED — archetype portion deferred to Phase 6]
|
||||
- **Date:** 2026-02-13
|
||||
- **Supersession note (2026-04-21 / amended 2026-04-22, Sprint 37, #878, PR #137):** The character-archetype verb variation portion of this decision is retired pending a Phase 6 character-model design — not deferred in place with a stub. Per the development cascade (CLAUDE.md), archetype-driven verb relabeling is Phase 6 detail-coloring and has been stripped from the server. Vertical-list structure, Phase 1/Phase 2 split, POI priority flips, and contradiction markers remain live. Relabeling (Open→"Move"/"Stash" vs "Scan"/"Flag") is deleted; container verb labels are now identical across all player states, and uniform labeling is the intended pre-Phase-6 end state, not a regression. Reintroduction gate: a confirmed Phase 6 character-model design is a prerequisite. See `docs/architecture/sprint-37-878-audit.md` (lead override section) for the cascade rationale.
|
||||
- **Decision:** Entity interactions use a compact vertical list (not radial). 2-4 options max, anchored to entity position. Insert-styled with Araminta's geometric aesthetic. New options unlocked by knowledge changes are highlighted with a gradient glow background. Radial menu reserved for world menu only ([D-058](#d-058-world-menu--radial-4-spokes)). Max 3 visible response options in dialogue context.
|
||||
- **Server architecture:** Two-phase verb computation. Phase 1 (simulation, no KG): compute maximum possible verb set from ObjectType component (Readable, Container, Terminal, Door, Pickup, Furniture — each with specific verb sets). Phase 2 (observer, reads KG): filter by character's knowledge (Confront requires KnowsDetails+ per [D-041](architecture.md#d-041-knowledge-graph-data-model)), apply POI priority flips, add contradiction markers. Character-archetype verb variation implemented as Phase 2 observer filter rules (same crate: smuggler sees "Move/Stash", detective sees "Scan/Flag").
|
||||
- **Server architecture:** Two-phase verb computation. Phase 1 (simulation, no KG): compute maximum possible verb set from ObjectType component (Readable, Container, Terminal, Door, Pickup, Furniture — each with specific verb sets). Phase 2 (observer, reads KG): filter by character's knowledge (Confront requires KnowsDetails+ per [D-041](architecture.md#d-041-knowledge-graph-data-model)), apply POI priority flips, add contradiction markers. ~~Character-archetype verb variation implemented as Phase 2 observer filter rules (same crate: smuggler sees "Move/Stash", detective sees "Scan/Flag").~~ *[Removed Sprint 37 — archetype verb relabeling deleted; see supersession note above.]*
|
||||
- **Diegetic test:** Labels render on z-layer 6. If insert is off, labels disappear.
|
||||
- **Rationale:** Variable-length text options (e.g., confrontation lines in character voice) break radial spatial memory. List handles 1-4 options cleanly. New-item glow signals "something changed" without UX hazard of geometry transforming under cursor. Two-phase computation enables character differentiation without separate verb systems.
|
||||
- **References:** Disco Elysium (world-embedded indicators), Darkwood (minimal cursor), Rimworld (right-click context list).
|
||||
|
||||
@@ -229,12 +229,24 @@ Narrative, NPCs, dialogue, templates, setting, worldbuilding, and storyteller me
|
||||
---
|
||||
|
||||
### Q-095: Commission formal name — authoritative designation
|
||||
- **Status:** Open
|
||||
- **Status:** Resolved — see [D-193](content.md#d-193-lattice-commission--canonical-long-form-of-the-commission) (2026-04-21)
|
||||
- **Resolution:** Canonical long-form is **"the Lattice Commission"**. Short form "the Commission" remains standard in-prose. "Concord Commission" and "Assembly Commission" drift forms are non-canonical.
|
||||
- **Question:** What is the full formal name of the Concord Assembly's regulatory authority? In-prose use is "the Commission", but several drift forms have appeared in copy reviews ("Concord Commission", "Assembly Commission", "Lattice Commission"). A canonical long-form is needed for formal/legal contexts, institutional signage, and wiki headers.
|
||||
- **Context:** The Commission certifies implant hardware, medical equipment, pharmaceutical production, and safety-critical manufactured goods under tractus-denominated fees. It has no authority in Compact member systems. Current glossary guidance is to use "the Commission" until the full name is resolved, but authors writing institutional POIs and formal documents need a decision.
|
||||
- **Affects:** Wiki content, marker POI naming (e.g. `"Commission — Sirius Office"`), formal legal text in tractus fee documentation, institutional signage.
|
||||
- **Affects:** Wiki content, marker POI naming (e.g. `"Lattice Commission — Sirius Office"`), formal legal text in tractus fee documentation, institutional signage.
|
||||
- **Source:** Sprint 35 copy review (PR #127)
|
||||
|
||||
---
|
||||
|
||||
*25 questions (10 resolved, 2 partially resolved, 13 open). Last updated: 2026-04-14 (Q-095 added — Commission formal name)*
|
||||
---
|
||||
|
||||
### Q-097: Strip "What They Don't Talk About" from corporation pages
|
||||
- **Status:** Open
|
||||
- **Question:** Should we remove "What They Don't Talk About" sections from corporation wiki pages? Currently 116 of 156 corp pages have this section. The argument: cultural silences are a population/system-level phenomenon — people carry them because of where they live, not who employs them. A mining company doesn't develop its own cultural ethos; its workers inherit the system's silences. Corporate secrecy (trade secrets, undisclosed contracts) is just business, not anthropology. Gate Corporation may be an exception as a civilization-scale institution, but Arbour Aggregates and Rush Mining are regional businesses whose people are system-people first.
|
||||
- **Context:** Pattern originated from Gate Corporation (which plausibly operates at civilization scale) and was applied uniformly to all corp pages during bulk authoring. Now reinforcing itself — reviewers flag its *absence* as a defect (Sprint 38 PR #140 round 1). If left in place, every new corp page will copy the pattern. Counter-argument: some corporate silences *are* distinct from system silences (e.g., a pharmaceutical company's certification history vs. the system's general cultural memory). The question is whether that justifies a dedicated section or whether it belongs inline in Operations/Market Position.
|
||||
- **Affects:** 116 wiki/corporations/*.md files, corp page template, reviewer expectations
|
||||
- **Source:** Sprint 38 PR #140 review discussion (2026-05-02)
|
||||
|
||||
---
|
||||
|
||||
*26 questions (11 resolved, 2 partially resolved, 13 open). Last updated: 2026-05-02 (Q-097 corp silences)*
|
||||
|
||||
@@ -17,9 +17,9 @@ Tracked questions awaiting discussion or resolution. Split by domain, mirroring
|
||||
|--------|-------|----------|---------|------|
|
||||
| Architecture | 46 | 6 | 1 | 39 |
|
||||
| Perception | 9 | 5 | 0 | 4 |
|
||||
| Content | 25 | 10 | 2 | 13 |
|
||||
| Content | 25 | 11 | 2 | 12 |
|
||||
| Scope | 17 | 6 | 2 | 9 |
|
||||
| **Total** | **97** | **27** | **5** | **65** |
|
||||
| **Total** | **97** | **28** | **5** | **64** |
|
||||
|
||||
*Updated 2026-04-05: Full recount. Q-002, Q-004, Q-005, Q-007 closed as resolved (D-117/D-166). Architecture index corrected (was 13, actually 46 — Q-060 through Q-094 were missing from index).*
|
||||
|
||||
@@ -35,4 +35,4 @@ Tracked questions awaiting discussion or resolution. Split by domain, mirroring
|
||||
|
||||
When in doubt about where a question belongs: if it constrains **how we build**, it's architecture. If it defines **what the player observes or knows**, it's perception. If it defines **narrative, NPCs, dialogue, or setting**, it's content. If it defines **what we ship or how big it is**, it's scope.
|
||||
|
||||
*97 questions. Last updated: 2026-04-14 (Q-095 added — Commission formal name).*
|
||||
*97 questions. Last updated: 2026-04-21 (Q-095 resolved — D-193 Lattice Commission).*
|
||||
|
||||
+52
-5
@@ -207,13 +207,55 @@ Schema: `content/_schema/checklist.schema.json`. The checklist format feeds into
|
||||
- **Advisory** — when knowledge catalogs (`content/global/knowledge/*.yaml`) have no fact definitions yet: lists referenced fact_ids and exits cleanly.
|
||||
- **Enforcing** — when catalogs are populated: fails on any `fact_id` reference that doesn't match a canonical definition.
|
||||
|
||||
## Pre-commit Hooks
|
||||
## Asset Pipeline — Generator-Driven DB (#855, #856, #857)
|
||||
|
||||
`server/data/systems.db` is a **read-only canonical snapshot** produced by three
|
||||
generators. It is committed to the repo so the client can ship it, but it is never
|
||||
the source of truth. Direct edits are forbidden — they are silently overwritten by
|
||||
the next regeneration.
|
||||
|
||||
### Generators
|
||||
|
||||
| Generator | Source | Runs via |
|
||||
|-----------|--------|----------|
|
||||
| `generate_brands` | `server/src/bin/generate_brands/main.rs` | `tooling/generate-brands` |
|
||||
| `import_economics` | `tooling/economy-db/import_economics.py` | `python3 tooling/economy-db/import_economics.py` |
|
||||
| `generate_atlas` | `tooling/planet-gen/generate_atlas.py` | `python3 tooling/planet-gen/generate_atlas.py --seed 42` |
|
||||
|
||||
Run all three at once with:
|
||||
|
||||
```bash
|
||||
make regen-db
|
||||
```
|
||||
|
||||
### Meta table stamp
|
||||
|
||||
After every successful non-dry-run, each generator writes a row to the `meta` table in
|
||||
`systems.db` recording the SHA-1 of its source file(s) and the schema file.
|
||||
|
||||
```bash
|
||||
make check-systems-db # Verify the stamp is fresh (exit 1 = stale)
|
||||
```
|
||||
|
||||
### Making a DB change
|
||||
|
||||
1. Edit source files (TOML, JSON, `markers.json`).
|
||||
2. `make regen-db`
|
||||
3. `git add server/data/systems.db`
|
||||
4. Commit with `chore(db): regen systems.db — <reason>`
|
||||
|
||||
For schema changes, also update `server/data/systems-schema.sql` and add migration DDL
|
||||
to `MIGRATION_SQL` in `import_economics.py`.
|
||||
|
||||
See `.claude/rules/asset-pipeline.md` for the full rule set.
|
||||
|
||||
## Pre-commit and Pre-push Hooks
|
||||
|
||||
Git hooks are stored in `.config/hooks/` (version-controlled). Activate them with:
|
||||
|
||||
```bash
|
||||
make setup # Includes hook installation
|
||||
make setup-hooks # Just hooks
|
||||
make install-hooks # Just hooks (also makes them executable)
|
||||
```
|
||||
|
||||
Or manually:
|
||||
@@ -224,9 +266,14 @@ git config core.hooksPath .config/hooks
|
||||
|
||||
Active checks:
|
||||
|
||||
| Check | Script | Behavior |
|
||||
|-------|--------|----------|
|
||||
| fact_id validation | `tooling/check-fact-ids` | Warns if catalogs are stubs; fails on unknown fact_ids when populated |
|
||||
| Hook | Check | Script | Behavior |
|
||||
|------|-------|--------|----------|
|
||||
| pre-commit | fact_id validation | `tooling/check-fact-ids` | Warns if catalogs are stubs; fails on unknown fact_ids when populated |
|
||||
| pre-push | GDScript parse | internal | Fails on any SCRIPT ERROR |
|
||||
| pre-push | Rust lint | internal | fmt + clippy |
|
||||
| pre-push | Python lint | internal | ruff |
|
||||
| pre-push | JSON syntax | internal | python3 -m json.tool |
|
||||
| pre-push | systems.db stamp | `tooling/check-systems-db-stamp` | Rejects stale DB when pushed (#857) |
|
||||
|
||||
The `core.hooksPath` setting uses a relative path (`.config/hooks`) that resolves per worktree, so it works correctly across all worktrees in the repository.
|
||||
|
||||
|
||||
@@ -213,15 +213,12 @@ Note: `ConfirmBookmark` is the **trigger** for transitioning from the character-
|
||||
|
||||
```rust
|
||||
/// The confirmed bookmark selection for the current session.
|
||||
/// Populated when `ConfirmBookmark` is processed. `None` during the
|
||||
/// character-creation phase (before confirm) and always `None` in a
|
||||
/// fresh session.
|
||||
/// Populated when `ConfirmBookmark` is processed. `None` fields during the
|
||||
/// character-creation phase (before confirm) and in a fresh session.
|
||||
///
|
||||
/// **v0.2 scope: transient only.** Not serialized — save/load of
|
||||
/// `SelectedBookmark` is deferred to Sprint 37 (follow-up ticket
|
||||
/// filed alongside #614). Add `Serialize`/`Deserialize` derives and
|
||||
/// wire into `SaveState` when that ticket is claimed.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
/// Serialized into `SaveStateV1.selected_bookmark` (#863) so that a loaded
|
||||
/// game remembers which bookmark and starting location were chosen.
|
||||
#[derive(Resource, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SelectedBookmark {
|
||||
pub bookmark_id: Option<String>,
|
||||
pub starting_location_id: Option<String>,
|
||||
@@ -231,15 +228,11 @@ pub struct SelectedBookmark {
|
||||
Downstream systems (apartment generator, skill seeder) read from this
|
||||
resource.
|
||||
|
||||
**Save/load scope (v0.2 deferred):** `SelectedBookmark` is transient for
|
||||
v0.2 — it lives in-memory from `ConfirmBookmark` through session end and
|
||||
is not persisted. A reload after quit returns the player to the
|
||||
character-creation screen. Promotion to persistent state (adding
|
||||
`Serialize`/`Deserialize` and threading into `SaveState` / #553) is
|
||||
tracked in a follow-up ticket for Sprint 37. `SelectedBookmark` must
|
||||
carry an inline `// TODO(sprint-37): serialize — see #<follow-up ticket>`
|
||||
comment in `server/src/bookmark/mod.rs` pointing at the follow-up so the
|
||||
omission is greppable.
|
||||
**Save/load scope (Sprint 37, #863):** `SelectedBookmark` is persisted into
|
||||
`SaveStateV1.selected_bookmark`. After `load_from_file` completes, the resource
|
||||
reflects the bookmark confirmed at session-start. Saves created before Sprint 37
|
||||
will deserialize the field as `SelectedBookmark::default()` (both fields `None`)
|
||||
via `#[serde(default)]` on the `SaveStateV1` field.
|
||||
|
||||
## 5. Content source — how bookmarks get into the registry
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# Sprint 37 #878 — CharacterArchetype audit (D-032 obsolete claim)
|
||||
|
||||
**Author:** Tyre (architecture)
|
||||
**Date:** 2026-04-21 (audit); 2026-04-22 (lead override amendment)
|
||||
**Ticket:** #878 — Audit and purge dead smuggler/detective character enum (D-032 obsolete)
|
||||
**Status:** CLOSED — lead override 2026-04-21: strip stays. See override section below.
|
||||
|
||||
---
|
||||
|
||||
## Lead override (2026-04-21)
|
||||
|
||||
**Decision:** STRIP the full `CharacterArchetype` trace from the server. The
|
||||
original audit (below) recommended Option A (no-op, docs-only) on the basis
|
||||
that grep identified five load-bearing consumers. The lead reframed the
|
||||
live-vs-filler determination:
|
||||
|
||||
> "It is live because we have not scrapped the system loading it in the
|
||||
> client. This is not live gameplay. Only the character creation elements
|
||||
> and the insert screens are actual production code. The rest is uncleaned
|
||||
> filler — per the cascade, we don't deal with character and NPC
|
||||
> instructions." — Jeroen, 2026-04-21
|
||||
|
||||
**Rationale — the cascade framing.** Per `CLAUDE.md`, development follows a
|
||||
strict six-phase cascade. Current focus is Phase 1 (wiki content). Character
|
||||
model, NPC differentiation, verb-label relabeling, and character-keyed
|
||||
monologue pools all belong to Phase 6 (detail coloring). Code that executes
|
||||
at runtime is **not** automatically production — if the topic belongs to a
|
||||
later cascade phase, it is pre-cascade filler regardless of how deeply it is
|
||||
wired in.
|
||||
|
||||
The audit's identification of `archetype_verb_label` as "live feature code"
|
||||
was mechanically correct: the code runs, produces output, and is observed on
|
||||
the wire. But the **feature itself is premature**. Container verb
|
||||
differentiation (Smuggler Move/Stash vs Detective Scan/Flag) and
|
||||
archetype-keyed monologue pools are Phase 6 detail, not Phase 1–3
|
||||
scaffolding. Uniform verb labels and a single monologue pool are the
|
||||
intended end state until a Phase 6 character system is designed — not a
|
||||
regression.
|
||||
|
||||
**Audit correction.** The audit's TL;DR conclusion ("v0.2 dropped reverses
|
||||
D-117 which reverses D-032-obsolete, therefore keep the enum") was the
|
||||
wrong frame. The supersession chain collapsed — yes — but the correct
|
||||
reading is that **all three decisions sit below the current cascade
|
||||
floor**, so the enum is cruft on cascade grounds independent of the D-117
|
||||
revocation. The audit should have consulted the cascade phase before
|
||||
grep-counting consumers; that is the non-obvious precedent captured in
|
||||
memory (`feedback_running_code_not_production.md`).
|
||||
|
||||
**Scope of the strip (Sprint 37, #878, merged via PR #137). Commit `cae3d3ab`
|
||||
— "refactor(simulation): strip archetype trace + HeritageRoot per cascade
|
||||
(#877, #878)" — is the authoritative file list; the summary below is the
|
||||
high-signal view:**
|
||||
|
||||
- `server/src/bridge/types.rs` — `CharacterArchetype` enum +
|
||||
`StartupMessage.character_archetype` field removed. Protocol break rides
|
||||
the `PROTOCOL_VERSION` drop in #874 (D-192), co-shipped in the same PR.
|
||||
- `server/src/perception/observer/mod.rs` — `apply_phase2_verb_filter`
|
||||
loses its archetype parameter; `archetype_verb_label` helper + the
|
||||
container-relabeling block deleted.
|
||||
- `server/src/simulation/monologue.rs` — `MonologueState.character`
|
||||
field + `Default` value removed. Pool selection is now uniform.
|
||||
- `server/src/simulation/examine.rs` — `generate_examine_text` collapses
|
||||
two archetype-specific branches into a single detective-style frame.
|
||||
- `server/src/test_world/mod.rs` + `server/src/main.rs` — `setup_gauntlet`
|
||||
and `setup_proof_room` drop the archetype parameter; all internal
|
||||
callsites + the production main loop updated.
|
||||
- `server/tests/archetype_monologue.rs` — deleted (regression guard for
|
||||
#587's archetype→monologue wiring; wiring itself deleted).
|
||||
- `server/tests/v01_integration_playthrough.rs` — deleted (5
|
||||
archetype-using integration tests; superseded by Gauntlet coverage).
|
||||
- `server/content/schemas/drama_module.schema.yaml` — deleted.
|
||||
- `server/content/modules/tier1/smuggling_ring_v0_1.yaml` — deleted.
|
||||
|
||||
**Client follow-up: ticket #882** — *"Strip archetype-driven client code
|
||||
(follow-up to #878 server)"*. The server strip leaves client-side code
|
||||
referencing the removed `character_archetype` wire field and archetype-
|
||||
keyed palette/monologue branches. Client cleanup preserves the character-
|
||||
creation UI and insert screens (production per the lead call) and strips
|
||||
`lattice_profile` branching, `character.txt` session I/O, and protocol
|
||||
decoding of the removed field. Blocked by this PR; cross-referenced in
|
||||
the server task description.
|
||||
|
||||
**Decision record amendments (2026-04-22):**
|
||||
|
||||
- `decisions/content.md` D-032 `[SUPERSEDED]` header rewritten to cite the
|
||||
cascade instead of the dropped D-117.
|
||||
- `decisions/content.md` D-035 monologue `character` enum note updated
|
||||
(was `[Obsolete post-D-117]`, now cites the cascade strip).
|
||||
- `decisions/perception.md` D-057 marked `[PARTIALLY SUPERSEDED]` with
|
||||
the archetype verb relabeling portion crossed out; vertical-list +
|
||||
Phase 1/2 split preserved.
|
||||
|
||||
**Regression guards for the new uniform behavior** are being added under
|
||||
separate tasks (Hoshe, Sprint 37) — positive assertions that container
|
||||
verb labels and monologue pool selection are archetype-independent, to
|
||||
prevent silent reintroduction.
|
||||
|
||||
**DECISION: STRIP.**
|
||||
|
||||
The original audit body below is retained as a historical record of the
|
||||
pre-override analysis. Do not take its recommendation as current.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (original audit, SUPERSEDED by lead override above)
|
||||
|
||||
The ticket's premise — that `CharacterArchetype` (Smuggler/Detective) is dead
|
||||
code per D-032's "[Obsolete post-D-117]" footnote — is **stale**. The
|
||||
footnote relied on D-117 (tycoon is the v0.2 bookmark), but `CLAUDE.md`
|
||||
now declares **"v0.2 target is dropped. No scoping negotiations."** That
|
||||
revocation is the most recent architectural directive, and it rolls back
|
||||
the premise that justified marking the enum obsolete.
|
||||
|
||||
Recommendation: **do not delete `CharacterArchetype`**. Instead, update
|
||||
the decision record to clear the stale obsolete footnote, close the
|
||||
ticket as "no-op — premise superseded," and (optionally) claim a small
|
||||
D-record documenting the reversal chain.
|
||||
|
||||
## Audit
|
||||
|
||||
Grep was run against `server/`, `tooling/`, and `tests/`. `CharacterArchetype`
|
||||
has **five load-bearing consumers** plus content-schema users:
|
||||
|
||||
### 1. IPC protocol surface
|
||||
- `server/src/bridge/types.rs:51-54` — `StartupMessage.character_archetype: CharacterArchetype`.
|
||||
Field is serialized into the session handshake. Removing it is a protocol break.
|
||||
- `server/src/bridge/types.rs:500-518` — enum + `as_monologue_key()` helper + `Default = Detective`.
|
||||
- Roundtrip tests at lines 1133, 1146, 1157, 1166, 1173, 1184 exercise the field.
|
||||
|
||||
### 2. Observer pipeline (D-057, #422) — real runtime behavior
|
||||
- `server/src/perception/observer/mod.rs:81,117,138,200` — archetype flows through
|
||||
`apply_phase2_verb_filter`.
|
||||
- `server/src/perception/observer/mod.rs:657-760` — `archetype_verb_label()` swaps
|
||||
container verb labels based on archetype (Smuggler sees `Move`/`Stash`, Detective
|
||||
sees `Scan`/`Flag`). This is live feature code, not scaffolding.
|
||||
|
||||
### 3. Monologue pool selection (D-032, #587, #595)
|
||||
- `server/src/simulation/monologue.rs:134-152` — `MonologueState.character: String` is
|
||||
initialized from `CharacterArchetype.as_monologue_key()` at session start.
|
||||
Pool partition is by string key, but the string is *derived* from the enum.
|
||||
|
||||
### 4. Gauntlet test-world plumbing
|
||||
- `server/src/test_world/mod.rs:98` — `pub fn setup_gauntlet(app: &mut App, archetype: CharacterArchetype)`.
|
||||
- 5 internal callsites (lines 702, 725, 742, 761, 778) plus the external
|
||||
`archetype_monologue.rs` integration suite.
|
||||
|
||||
### 5. Regression test suite
|
||||
- `server/tests/archetype_monologue.rs` — entire file is a regression guard
|
||||
against #587 (archetype→monologue character wiring). Seven tests, four
|
||||
explicitly assert Smuggler vs Detective behavior. Deleting the enum requires
|
||||
deleting this guard, which is the thing that catches the bug it was built for.
|
||||
|
||||
### 6. Content schemas (authoring)
|
||||
- `server/content/schemas/drama_module.schema.yaml:231,233,234,280,558` —
|
||||
schema enumerates `smuggler | detective | any` for dialogue/monologue
|
||||
partitioning in drama modules.
|
||||
|
||||
## Decision chain (why the ticket premise is stale)
|
||||
|
||||
```
|
||||
D-027 (v0.1 vertical slice = smuggler + detective)
|
||||
└─ superseded by D-117 (2026-03-05: tycoon is the v0.2 bookmark)
|
||||
└─ superseded by "v0.2 target is dropped" (CLAUDE.md, current)
|
||||
```
|
||||
|
||||
The obsolete footnote in D-035 line 83 and the `[SUPERSEDED]` header on
|
||||
D-032 both point at D-117 as the supersession. With v0.2 dropped, we are
|
||||
back to the v0.1 smuggler/detective frame as the implemented base until
|
||||
the 6-phase cascade reaches Phase 4 (Player control) — and even then,
|
||||
the cascade describes a 2-floor test map + character rendering, not a
|
||||
wholesale character-model replacement.
|
||||
|
||||
## Proposed alternative scope for #878
|
||||
|
||||
Three options, cheapest first:
|
||||
|
||||
### A. Close as no-op + documentation cleanup (recommended)
|
||||
- Strip the `[Obsolete post-D-117]` footnote from `decisions/content.md:83`
|
||||
(D-035 tag taxonomy).
|
||||
- Remove the `[SUPERSEDED]` marker from `decisions/content.md:47` (D-032
|
||||
header) or add a "supersession reversed" note.
|
||||
- Optionally claim a new D-record in `decisions/scope.md` documenting
|
||||
that the v0.2-dropped directive implicitly reverses D-117's character
|
||||
frame revocation.
|
||||
- Zero code changes. Build stays green. 30 minutes.
|
||||
|
||||
### B. Narrow the ticket to the authoring-side leftovers
|
||||
- If there are *authoring* artifacts (half-written tycoon monologue
|
||||
partitioning, stale schema fields) that were added in anticipation of
|
||||
D-117 and never used, those can be purged. But a quick scan of
|
||||
`drama_module.schema.yaml` shows the schema is consistent with v0.1 usage.
|
||||
- Requires a content team review — server-team scope alone cannot
|
||||
confirm what is live in authoring.
|
||||
|
||||
### C. Rename without removing (if lead wants distance from v0.1 framing)
|
||||
- Rename `CharacterArchetype` → `PlayerCharacterRole` (or similar) and
|
||||
its variants to preserve behavior while shedding the "smuggler/detective
|
||||
investigation framing" language. Higher risk, touches ~45 files, and
|
||||
doesn't actually change runtime. **Not recommended** unless the lead
|
||||
specifically wants the naming to match post-cascade vocabulary.
|
||||
|
||||
## Recommendation (SUPERSEDED — see "Lead override" at top)
|
||||
|
||||
~~Go with **Option A**. The enum is architecturally sound, the ticket is
|
||||
a casualty of the v0.2→cascade pivot, and the cleanup is documentation-
|
||||
only.~~
|
||||
|
||||
**Actual decision:** strip (Option D, not enumerated above — full trace
|
||||
purge driven by cascade framing, not by the D-117/v0.2 supersession
|
||||
chain). The audit's grep-count-first methodology was the wrong starting
|
||||
heuristic; cascade phase comes first. See top of document.
|
||||
Binary file not shown.
@@ -0,0 +1,88 @@
|
||||
# Sprint 37: Sweep — CI Tasks
|
||||
|
||||
**Goal:** Spring-clean accumulated debt: asset pipeline discipline, PROTOCOL_VERSION removal, D-167/D-032 dead-code purge, New Game regression fix, copy wiki residue, bookmark save-state, and generator quality patches.
|
||||
|
||||
**Branch:** `sprint-37/ci`
|
||||
**Agents:** Gestalt (systems), Hoshe (QA)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #854 | Generator-driven asset pipeline — regeneration discipline and versioning (epic) | high | — |
|
||||
| #855 | systems.db regeneration with versioning awareness | high | #854 |
|
||||
| #856 | Stamp systems.db with generator metadata (meta table) | high | #855 |
|
||||
| #857 | Pre-push git hook for systems.db consistency | high | #856 |
|
||||
| #858 | Extend /pr-push with rebase + regen + stage | high | #856 |
|
||||
| #859 | Document the source-canonical rule (.claude/rules/asset-pipeline.md + DEVOPS) | medium | #856 |
|
||||
| #723 | Add decision-to-ticket coverage report | medium | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
No domain decisions gate this sprint's work. The asset pipeline epic creates new process conventions documented in `.claude/rules/asset-pipeline.md` (deliverable of #859).
|
||||
|
||||
## Notes
|
||||
|
||||
**#854 — Generator-driven asset pipeline (epic wrapper)**
|
||||
- This epic establishes the mental model: `server/data/systems.db` is a read-only canonical snapshot, deterministically produced from source files (markers.json, *.toml, generator code). Direct DB edits are forbidden except when mirrored back to source.
|
||||
- Children: #855 → #856 → #857 and #858 (parallel after #856) → #859 (docs, after #856).
|
||||
- Driver: Sprint 36 had a binary conflict risk between two branches both committing systems.db changes. This epic prevents the class of problem going forward and establishes the versioning primitives the future savegame system will need.
|
||||
- #854 itself has no code deliverable — it is the parent epic for tracking purposes. Work happens in children.
|
||||
|
||||
**#855 — systems.db regeneration with versioning awareness**
|
||||
- Every generator (`import_economics.py`, `generate_atlas.py`, `generate_brands`) must be updated to accept a `--stamp` flag (or equivalent) that writes metadata to the DB after generation.
|
||||
- The regeneration step should be idempotent: running it twice on the same sources produces identical output.
|
||||
- Unblocks #856, #857, #858, #859.
|
||||
|
||||
**#856 — Stamp systems.db with generator metadata (meta table)**
|
||||
- Add a `meta` table to `db/schema.sql`: columns `schema_version`, `generator_sha`, `generated_at`.
|
||||
- Each generator writes its own row on completion.
|
||||
- The stamp is what lets a future savegame DB record which canonical snapshot it derives from (migration lineage).
|
||||
- Blocked by #855 (generators must be updated before stamping is meaningful).
|
||||
|
||||
**#857 — Pre-push git hook for systems.db consistency**
|
||||
- Hook fires on `git push` and verifies: if `systems.db` is staged, its `meta.generator_sha` matches the current HEAD SHA of the generator source files.
|
||||
- If not, rejects the push with a message: "systems.db is stale — run `make regen-db` before pushing."
|
||||
- Install via `make install-hooks`. Document in #859.
|
||||
|
||||
**#858 — Extend /pr-push with rebase + regen + stage**
|
||||
- The `/pr-push` skill runs before PR creation. Extend it to: (1) rebase on main, (2) run `make regen-db` if any generator source was modified in the branch, (3) stage the updated `systems.db`.
|
||||
- This prevents the class of binary conflict where two branches both modify generator sources and commit separate DB snapshots.
|
||||
- Parallel to #857 after #856 is done.
|
||||
|
||||
**#859 — Document the source-canonical rule**
|
||||
- Deliverables:
|
||||
1. `.claude/rules/asset-pipeline.md` — canonical rules file covering: what systems.db is, how to make a DB change, why direct edits are forbidden, the meta table stamp, the pre-push hook, why /pr-push regenerates.
|
||||
2. `CLAUDE.md` hook — one-line reference under a new `### Asset pipeline` subsection pointing at the rules file.
|
||||
3. `docs/DEVOPS.md` — short section on the pipeline: meta table, `make install-hooks`, regenerate-before-push workflow.
|
||||
- Blocked by #856 (meta table must exist before documenting it).
|
||||
|
||||
**#723 — Decision-to-ticket coverage report**
|
||||
- No schema change needed — `tickets.decision_ref` already exists.
|
||||
- Deliverables:
|
||||
1. `make decisions-coverage` — shows each D-record with its implementing ticket(s).
|
||||
2. `make decisions-orphan` — shows D-records with no implementing tickets (verify existing target works).
|
||||
3. `tooling/db/decision show D-159` — includes linked ticket IDs in output.
|
||||
- The reverse link (decision → tickets) is a `SELECT` query, not a new column. Avoids fragile two-way sync.
|
||||
- Standalone, parallel to the asset pipeline chain.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#854 (epic) → no code
|
||||
#855 (regen awareness) → #856 (meta table) → #857 (pre-push hook)
|
||||
→ #858 (pr-push extension)
|
||||
→ #859 (documentation)
|
||||
#723 → standalone, parallel to all above
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "ci: sprint 37 — asset pipeline discipline, decision coverage report" \
|
||||
--description "body" \
|
||||
--base main --head sprint-37/ci
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# Sprint 37: Sweep — Client Tasks
|
||||
|
||||
**Goal:** Spring-clean accumulated debt: asset pipeline discipline, PROTOCOL_VERSION removal, D-167/D-032 dead-code purge, New Game regression fix, copy wiki residue, bookmark save-state, and generator quality patches.
|
||||
|
||||
**Branch:** `sprint-37/client`
|
||||
**Agents:** Stig (dev), Hoshe (QA)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #872 | New Game flow hangs on 'connecting' after sprint-36 Option A handoff | high | — |
|
||||
| #873 | Scene-level merge-path UI flow tests (gdUnit4) | high | — |
|
||||
| #866 | dialogue_box _escape_bbcode chained replace corrupts [lb] escapes | high | — |
|
||||
| #869 | Fix MetaScreen test helper regression — anti_tedium suite (7 fails) | high | — |
|
||||
| #870 | Delete or revive 8 parse-error test files | medium | — |
|
||||
| #875 | Drop PROTOCOL_VERSION on client (D-192) | medium | #874 (server) |
|
||||
| #882 | Strip archetype-driven client code (follow-up to #878) | medium | #878 (server) |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-192 (drop PROTOCOL_VERSION lockstep handshake), D-005 (IPC protocol)
|
||||
|
||||
## Notes
|
||||
|
||||
**#872 — New Game flow hangs on 'connecting' (motivating regression)**
|
||||
- Manual repro during Sprint 36 PR #134 smoke test: main menu → New Game → loading screen shows 'connecting' and never progresses.
|
||||
- Likely suspects:
|
||||
1. `client/ui/meta/screens/main_menu/main_menu.gd` — `_process()` polls `SimBridge.poll_snapshot()` while `_waiting_for_catalog` is true. If `bookmark_catalog` never arrives (or arrives but the flag isn't cleared), the loop runs forever.
|
||||
2. `client/ui/meta/screens/loading/loading_screen.gd` — opaque BG + `MOUSE_FILTER_STOP` means a never-dismissed loading state looks identical to 'hung connecting'.
|
||||
3. `client/scripts/protocol/protocol.gd` — `bookmark_catalog` decode landed in v23 (commit 41e89579). Verify server is actually sending `bookmark_catalog` in the first snapshot after connect.
|
||||
4. Option A scene handoff race — confirm `main_menu → character_creation` triggers on a signal, not a polled flag.
|
||||
- Suggested investigation order: add trace prints in `main_menu._process` to confirm `SimBridge.state` and `GameState.bookmark_catalog` contents during the hang. That will disambiguate suspects 1 vs 3 in minutes.
|
||||
|
||||
**#873 — Scene-level merge-path UI flow tests (gdUnit4)**
|
||||
- Add gdUnit4 scene-level tests for merge-path UI flows so regressions like #872 are caught pre-merge instead of post-merge.
|
||||
- Scope — one test per flow:
|
||||
- main menu → new game → character creation → confirm → connected state
|
||||
- main menu → load game → save picker → selected
|
||||
- character creation → submit → `sim_bridge` receives correct payload
|
||||
- bookmark tab → select location → confirm → server gets bookmark action
|
||||
- Tests must run headless via `tests/run-godot`. Pattern: load scene → simulate input via `_input()` or `button.pressed.emit()` → await signals or poll state with timeout → assert terminal state.
|
||||
- NOT pixel/screenshot diffing. NOT RPA/xdotool. NOT CI integration (existing `make test-client` already covers this).
|
||||
- Reference pattern: `test_character_creation_sprint28.gd`.
|
||||
- These tests are the mechanism the `pr-review` merge-path gate (added Sprint 36 retro) assumes exists.
|
||||
|
||||
**#866 — _escape_bbcode chained replace corruption**
|
||||
- `dialogue_box.gd:628` chains `.replace('[', '[lb]').replace(']', '[rb]')`. The second replace turns `[lb]` into `[lb[rb]`, corrupting the escape. BBCode injection guard is effectively broken for server-sourced text.
|
||||
- Fix: escape only `[` (not `]`), since unmatched `]` in RichTextLabel renders as literal.
|
||||
- Test: `test_escape_bbcode_brackets_in_server_text` in `test_dialogue_sprint18.gd` (currently skipped) — unskip and make it pass.
|
||||
|
||||
**#869 — MetaScreen test helper regression (7 fails)**
|
||||
- Sprint 36 migrated `bug_report_dialog.gd` from `extends Control` to `extends MetaScreen`. `test_anti_tedium.gd:94` builds the dialog via `Control.new() + set_script(BugReportDialogScript)`, which no longer satisfies the MetaScreen base contract.
|
||||
- Every call to `dialog.start_capture()` fails with `Nonexistent function in base Control`. 7 failing tests in the anti_tedium suite.
|
||||
- Fix: instantiate the `.tscn` (preserves the MetaScreen runtime stack) instead of building from script, OR have the test instantiate a MetaScreen-rooted node.
|
||||
|
||||
**#870 — Delete or revive 8 parse-error test files**
|
||||
- These 8 files fail to parse (not just fail tests), contributing to gdUnit error counts: `test_debug_overlay_sprint19.gd`, `test_entanglement_sprint22.gd`, `test_fog_sprint22.gd`, `test_journal_sprint18.gd`, `test_minimap_sprint18.gd`, `test_session_manager_sprint19.gd`, `test_sprint30.gd`, `test_sprite_integration.gd`.
|
||||
- They reference removed/renamed APIs from prior sprints.
|
||||
- Default: delete. File a fresh ticket if/when the underlying coverage is needed again. If any file is worth reviving, rewrite it against the current API.
|
||||
|
||||
**#875 — Drop PROTOCOL_VERSION on client (D-192)**
|
||||
- Blocked by server ticket #874 — merge server PR first.
|
||||
- Remove: `version` field read in `client/scripts/protocol/protocol.gd` (`Protocol.decode_snapshot`), the `PROTOCOL_VERSION` constant, and the version-mismatch guard.
|
||||
- Update any fixture-replay paths that read `version`.
|
||||
- Keep all field-presence and roundtrip behavioral tests.
|
||||
|
||||
**#882 — Strip archetype-driven client code (follow-up to server #878)**
|
||||
- Added 2026-04-21. Blocked by server #878 (removes `character_archetype` from StartupMessage; rides #874's PROTOCOL_VERSION break).
|
||||
- Per lead direction: the CharacterArchetype trace is Phase 6 filler, not production. Keep character-creation UI and insert screens; strip everything else.
|
||||
- Strip: `character_archetype` field in `game_state.gd` (line 100), `lattice_profile` derivation (line 47); `session_manager.gd` `save_character_archetype()`, `_read_archetype_file()`, character.txt save/load (lines 58, 172–192); `protocol.gd` `character_archetype` on StartupMessage; `sim_bridge.gd` archetype wire-up; any monologue color-palette code keyed on `lattice_augmented`/`lattice_baseline`; audit `tests/client/test_signal_sprint24.gd`.
|
||||
- Verify: client launches, character creation UI loads, insert screens render, session starts. Grep `character_archetype`, `lattice_profile`, `smuggler`, `detective` in `client/` — only character-creation UI references remain.
|
||||
- Context: server-side audit at `docs/architecture/sprint-37-878-audit.md` (on server branch until #878 merges).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#874 (server) → #875 (client PROTOCOL_VERSION drop)
|
||||
#878 (server) → #882 (client archetype strip)
|
||||
#872 (New Game regression fix) → #873 (merge-path tests add coverage for this flow)
|
||||
#866, #869, #870 → standalone, parallel
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "fix(client): sprint 37 — New Game regression, test cleanup, PROTOCOL_VERSION drop" \
|
||||
--description "body" \
|
||||
--base main --head sprint-37/client
|
||||
```
|
||||
@@ -0,0 +1,65 @@
|
||||
# Sprint 37: Sweep — Copy Tasks
|
||||
|
||||
**Goal:** Spring-clean accumulated debt: asset pipeline discipline, PROTOCOL_VERSION removal, D-167/D-032 dead-code purge, New Game regression fix, copy wiki residue, bookmark save-state, and generator quality patches.
|
||||
|
||||
**Branch:** `sprint-37/copy`
|
||||
**Agents:** Mellanie (author), Paula (narrative), Miri (consistency)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #876 | decide(content): Commission formal name — canonical long-form designation (Q-095) | medium | — |
|
||||
| #865 | Purge remaining v0.1 Sova/Van Maanen's residue from wiki | medium | — |
|
||||
| #861 | Author 112 brand corp wiki stubs to three-layer narrative depth | medium | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/questions.md` — Q-095 (Commission formal name — Concord Commission vs. Lattice Commission)
|
||||
- `decisions/content.md` — D-128 (culture implicit in starting location), D-122 (all NPCs generated)
|
||||
- `decisions/economics.md` — D-189 (brand layer architecture — 8 categories), D-185 (brands are not commodities)
|
||||
|
||||
## Open Questions to Resolve Early
|
||||
|
||||
- **Q-095: Commission formal name** — Drift between "Concord Commission" and "Lattice Commission" blocks institutional POI signage and tractus fee legal text. Jeroen picks the long-form name; Mellanie/Paula codify everywhere. Resolve in the first session of the sprint before #865 touches faction files.
|
||||
|
||||
## Notes
|
||||
|
||||
**#876 — Commission formal name decision (Q-095)**
|
||||
- One-conversation resolution. The two drifted names appear in: faction wiki pages, tractus fee descriptions, legal-text authoring guide examples, and institution templates.
|
||||
- Deliverable: a confirmed D-record (claim an ID with `tooling/db/decision claim D content "Commission formal name"` before writing). Update `decisions/questions.md` to mark Q-095 resolved with the D-record reference.
|
||||
- Do this first — #865 will touch faction files and should use the canonical name.
|
||||
|
||||
**#865 — Purge v0.1 Sova/Van Maanen's residue from wiki**
|
||||
- Start by running `grep -rln 'v0\.1\|Sova Transit\|Van Maanen\|Kael Davan\|Sera Venn\|Nils Davan\|the-ring' wiki/` to scope the full hit list.
|
||||
- Confirm three decision points with Jeroen before bulk editing:
|
||||
1. Sova/Transit tree under `wiki/star-systems/GJ-35/sova/` — delete outright, or reparent/retag as a legitimate Vuurkloof station?
|
||||
2. Example references in authoring guides/templates — keep as illustrative voice examples, or replace with galactic-scope alternatives?
|
||||
3. Decision/lore files citing Kael/Sera/etc. as canonical — rewrite to generic, or delete those sections?
|
||||
- This is judgment-heavy cleanup, not a mechanical find/replace. Confirm the decision points first; do the bulk editing after.
|
||||
- Resolve Q-095 (#876) before touching faction files so the correct Commission name is used throughout.
|
||||
|
||||
**#861 — Author 112 brand corp wiki stubs to three-layer narrative depth**
|
||||
- Sprint 36 PR #133 shipped 112 new corp wiki pages in `wiki/corporations/` as 24-line frontmatter-only placeholders. They need authoring to the canonical three-layer standard.
|
||||
- Reference template: Calloway, Thrds, VGV, Thalassa stubs (95–125 lines each). Three layers: (1) public identity, (2) actual operation, (3) one concealed fact.
|
||||
- DoD per page: ≥95 lines, all three layers present, cross-refs populated, corridor/founding-system specificity (not generic boilerplate).
|
||||
- This is a full sprint's worth of work for the team. Do not attempt to cram into a single session — divide by corridor or category across Mellanie/Paula/Miri and review as you go.
|
||||
- Paula reviews narrative as authoring progresses. Miri checks cross-ref consistency.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#876 (Q-095 Commission name) → #865 (wiki residue purge touches faction files)
|
||||
#861 → standalone, parallel to both above
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "content(wiki): sprint 37 — brand corp stubs, wiki residue purge, Commission name" \
|
||||
--description "body" \
|
||||
--base main --head sprint-37/copy
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# Sprint 37: Sweep — Joint Overview
|
||||
|
||||
**Sprint goal:** Spring-clean accumulated debt: asset pipeline discipline, PROTOCOL_VERSION removal, D-167/D-032 dead-code purge, New Game regression fix, copy wiki residue, bookmark save-state, and generator quality patches.
|
||||
|
||||
**Sprint 37 is a maintenance and polish sprint.** No new features. Every ticket is either closing a decision-without-follow-through, fixing a regression, establishing infrastructure discipline, or clearing content debt.
|
||||
|
||||
## Pre-Sprint Decisions
|
||||
|
||||
| Decision | Status | Owner | Blocks |
|
||||
|----------|--------|-------|--------|
|
||||
| Q-095: Commission formal name | Open — resolve sprint day 1 | copy (Jeroen picks) | #876, #865 |
|
||||
| D-192: Drop PROTOCOL_VERSION | Confirmed | server then client | #874 → #875 |
|
||||
| D-167: HeritageRoot removal | Confirmed | server | #877 |
|
||||
| D-032: Smuggler/detective enum | Confirmed obsolete | server | #878 |
|
||||
|
||||
## Full Ticket Roster
|
||||
|
||||
| Team | # | Title | Priority |
|
||||
|------|---|-------|----------|
|
||||
| ci | #854 | Generator-driven asset pipeline (epic) | high |
|
||||
| ci | #855 | systems.db regeneration with versioning awareness | high |
|
||||
| ci | #856 | Stamp systems.db with generator metadata (meta table) | high |
|
||||
| ci | #857 | Pre-push git hook for systems.db consistency | high |
|
||||
| ci | #858 | Extend /pr-push with rebase + regen + stage | high |
|
||||
| ci | #859 | Document the source-canonical rule | medium |
|
||||
| ci | #723 | Add decision-to-ticket coverage report | medium |
|
||||
| server | #853 | Generator-patch follow-up: dedup, mountains, suffix, compass | medium |
|
||||
| server | #860 | Resolve 21 raw-commodity / system coverage gate gaps | medium |
|
||||
| server | #863 | Wire SelectedBookmark into SaveState | medium |
|
||||
| server | #874 | Drop PROTOCOL_VERSION on server (D-192) | medium |
|
||||
| server | #877 | Remove HeritageRoot type alias and Heritage variant (D-167) | medium |
|
||||
| server | #878 | Audit and purge dead smuggler/detective enum (D-032) | medium |
|
||||
| server | #789 | Storyteller activation_pass log should be DEBUG not WARN | low |
|
||||
| server | #847 | Determinism smoke test for generate_atlas.py | low |
|
||||
| server | #862 | Refactor BookmarkPlugin to accept injected registry | low |
|
||||
| client | #866 | dialogue_box _escape_bbcode chained replace corrupts [lb] | high |
|
||||
| client | #869 | Fix MetaScreen test helper regression — anti_tedium (7 fails) | high |
|
||||
| client | #872 | New Game flow hangs on 'connecting' | high |
|
||||
| client | #873 | Scene-level merge-path UI flow tests (gdUnit4) | high |
|
||||
| client | #870 | Delete or revive 8 parse-error test files | medium |
|
||||
| client | #875 | Drop PROTOCOL_VERSION on client (D-192) | medium |
|
||||
| copy | #861 | Author 112 brand corp wiki stubs to three-layer depth | medium |
|
||||
| copy | #865 | Purge remaining v0.1 Sova/Van Maanen's residue from wiki | medium |
|
||||
| copy | #876 | decide(content): Commission formal name (Q-095) | medium |
|
||||
|
||||
**Total: 25 tickets across 4 teams.**
|
||||
|
||||
## Cross-Team Dependencies
|
||||
|
||||
```
|
||||
server #874 (PROTOCOL_VERSION drop) → client #875
|
||||
copy #876 (Commission name) → copy #865 (faction files use resolved name)
|
||||
ci #855 → #856 → #857, #858, #859 (asset pipeline chain)
|
||||
```
|
||||
|
||||
## Sprint Completion Criteria
|
||||
|
||||
The sprint is done when all of the following are true:
|
||||
|
||||
1. `make economy-db` passes end-to-end with no coverage gate failures (#860 done).
|
||||
2. `make game` → New Game → character creation completes without hanging (#872 done).
|
||||
3. Scene-level gdUnit4 tests exist for all four merge-path flows and pass headless (#873 done).
|
||||
4. `server/src/bridge/types.rs` contains no `PROTOCOL_VERSION` constant and no `version` field in the snapshot envelope (#874 done).
|
||||
5. `client/scripts/protocol/protocol.gd` contains no version-mismatch guard (#875 done, after #874).
|
||||
6. `server/src/simulation/generator.rs` contains no `HeritageRoot` type alias and no `Heritage` enum variant (#877 done).
|
||||
7. `server/data/systems.db` carries a `meta` table with `schema_version`, `generator_sha`, `generated_at` (#856 done).
|
||||
8. `git push` on a branch with a stale systems.db is rejected by the pre-push hook (#857 done).
|
||||
9. `.claude/rules/asset-pipeline.md` exists and is referenced from `CLAUDE.md` (#859 done).
|
||||
10. All 112 new brand corp wiki pages are ≥95 lines with three-layer content (#861 done).
|
||||
11. Q-095 is resolved: a D-record exists for the Commission formal name (#876 done).
|
||||
12. `tooling/db/ticket list --sprint 37` shows all 25 tickets as `done`.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Sprint 37: Sweep — Server Tasks
|
||||
|
||||
**Goal:** Spring-clean accumulated debt: asset pipeline discipline, PROTOCOL_VERSION removal, D-167/D-032 dead-code purge, New Game regression fix, copy wiki residue, bookmark save-state, and generator quality patches.
|
||||
|
||||
**Branch:** `sprint-37/server`
|
||||
**Agents:** Dudley (dev), Tyre (arch), Hoshe (QA)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #863 | Wire SelectedBookmark into SaveState | medium | — |
|
||||
| #874 | Drop PROTOCOL_VERSION on server (D-192) | medium | — |
|
||||
| #853 | Generator-patch follow-up: dedup, mountains, suffix, compass | medium | — |
|
||||
| #860 | Resolve 21 raw-commodity / system coverage gate gaps | medium | — |
|
||||
| #877 | Remove HeritageRoot type alias and Heritage enum variant (D-167) | medium | — |
|
||||
| #878 | Audit and purge dead smuggler/detective character enum (D-032) | medium | — |
|
||||
| #847 | Determinism smoke test for generate_atlas.py | low | — |
|
||||
| #789 | fix(simulation): storyteller activation_pass log DEBUG not WARN | low | — |
|
||||
| #862 | Refactor BookmarkPlugin to accept injected registry | low | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-192 (drop PROTOCOL_VERSION lockstep handshake), D-005 (IPC protocol)
|
||||
- `decisions/content.md` — D-167 (corridors replace heritage roots, 2026-03-24)
|
||||
- `decisions/perception.md` — D-032 (smuggler/detective enum obsolete post-D-117; D-121 culture-driven voice)
|
||||
|
||||
## Notes
|
||||
|
||||
**#863 — Wire SelectedBookmark into SaveState**
|
||||
- Add `Serialize`, `Deserialize` derives to `SelectedBookmark` in `server/src/bookmark/mod.rs`.
|
||||
- Register `SelectedBookmark` as a serializable resource in `server/src/simulation/save_state.rs`.
|
||||
- Round-trip test: save with bookmark selected → load → `SelectedBookmark` survives.
|
||||
- Update bookmark spec §4.4 (`docs/architecture/sprint-36-bookmark-spec.md`) — remove the v0.2-deferred scope note.
|
||||
- Replaces inline TODO at `server/src/bookmark/mod.rs:95-99`.
|
||||
|
||||
**#874 — Drop PROTOCOL_VERSION on server (D-192)**
|
||||
- Remove: `version` field from snapshot envelope in `server/src/bridge/types.rs`, `PROTOCOL_VERSION` constant, and version stamp in encode path.
|
||||
- Keep all field-presence and roundtrip behavioral tests. Tautological version-literal assertions were already deleted in Sprint 36.
|
||||
- Client ticket #875 is blocked by this — server PR merges first.
|
||||
- After this lands, genuine schema drift surfaces as MessagePack missing-field errors downstream. That is the intended signal.
|
||||
|
||||
**#853 — Generator-patch follow-up**
|
||||
- Patch `tooling/planet-gen/generate_atlas.py` and `tooling/planet-gen/gemma_naming.py` for 7 systematic issues documented in `docs/design/atlas-generator-refinement-notes.md`:
|
||||
1. Cross-body city name dedup (49 collisions — 'Jade Harbor' on 20 bodies, 'Fort Iron' on 10)
|
||||
2. Cross-body mountain name dedup (186 bodies with empty names, 'Riverbend' on 39)
|
||||
3. Suffix monotony (-rant/-berg clustering)
|
||||
4. Compass-direction defaults — forbid 'Eastern X / Western Y' pattern in few-shot
|
||||
5. Cultural crossmix gap — thread corridor + cultural-history context into naming prompt
|
||||
6. River vocabulary bleeding from navigational terms (blocklist circumflex/contraflow)
|
||||
7. Unnamed infrastructure — emit `<City-A>-<City-B>` convention for roads/rail at generation time
|
||||
- Verification: re-run Paula's analysis script after patching; confirm collision counts drop.
|
||||
|
||||
**#860 — Resolve 21 coverage gate gaps**
|
||||
- `make economy-db` Phase 2 coverage gate fails on 21 raw-commodity/system gaps in tier1 corp tags. The brand layer (V-B01..V-B06) passes cleanly — this is upstream.
|
||||
- Identify the 21 specific gaps. Either backfill tier1 corp tags or relax the gate if gaps are intentional (e.g. commodity deliberately imported from outside the Reach).
|
||||
- `make economy-db` must pass end-to-end before Phase 2 demand simulation can run in anger.
|
||||
|
||||
**#877 — Remove HeritageRoot + Heritage variant (D-167)**
|
||||
- D-167 (2026-03-24) retired the 7 abstract heritage roots in favor of corridors. Two dead stubs remain:
|
||||
- `pub type HeritageRoot = String;` at `server/src/simulation/generator.rs:76`
|
||||
- `ZonePaletteModifier::Heritage(HeritageRoot)` variant at `generator.rs:369`
|
||||
- Delete the type alias, drop the enum variant, remove all Heritage modifier construction sites.
|
||||
- Verify no external consumer before deleting. Same pattern as D-192 cleanup.
|
||||
|
||||
**#878 — Audit and purge D-032 dead enum**
|
||||
- D-032 is marked "[Obsolete post-D-117: smuggler/detective eliminated. v0.2 uses culture-driven voice per D-121; this enum is unused.]"
|
||||
- Known reference sites: `server/src/bridge/types.rs`, `server/src/perception/observer/mod.rs`, `server/tests/archetype_monologue.rs`, and content schemas.
|
||||
- Audit first — some usages may be load-bearing (e.g. a wider pattern match). Remove confirmed-dead surface only. Do not mass-delete before reading each callsite.
|
||||
|
||||
**#847 — Determinism smoke test for generate_atlas.py**
|
||||
- ~60 lines of bash or pytest. Place in `tests/` consistent with project test layout.
|
||||
- Run `generate_atlas.py --body <small-body> --force --seed 42` twice; diff `markers.json` byte-for-byte.
|
||||
- Fails if output differs. Guardrail against determinism regressions in terrain analysis, city placement, A*, and naming.
|
||||
|
||||
**#789 — Storyteller log level fix**
|
||||
- `activation_pass: no Simmering triangles — holding` fires as WARN every few seconds during early gameplay when no NPC relationships have escalated. This is normal state, not an error condition.
|
||||
- Change to DEBUG or TRACE. One-line fix.
|
||||
|
||||
**#862 — BookmarkPlugin registry injection**
|
||||
- `BookmarkPlugin::build` calls `register_default_bookmarks(&mut registry)` unconditionally — no hook for a test registry.
|
||||
- Proposed: `BookmarkPlugin::new(registry)` injection. Default constructor still wires the canonical tycoon registry; injection variant is for tests and future TOML loading.
|
||||
- File: `server/src/bookmark/mod.rs:107-118`. Flagged in PR #132 review; accepted as follow-up.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#874 (server PROTOCOL_VERSION drop) → unblocks #875 (client)
|
||||
#877, #878, #789, #847, #862 → standalone, parallel
|
||||
#863, #853, #860 → standalone, parallel
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz \
|
||||
--title "refactor(server): sprint 37 — dead code purge, protocol cleanup, generator patches" \
|
||||
--description "body" \
|
||||
--base main --head sprint-37/server
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
# Sprint 38: Depth — CI Tasks
|
||||
|
||||
**Goal:** Map the generation pipeline from planetary heightmaps to walkable tile environments, formally park character/apartment work behind the full cascade, and close test/infra debt from Sprint 37.
|
||||
|
||||
**Branch:** `sprint-38/ci`
|
||||
**Agents:** Justine (build/deploy)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #887 | Add decisions-orphan-tickets CLI | low | — |
|
||||
| #888 | Switch meta.schema_version to monotonic semver | low | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Notes
|
||||
|
||||
**#887 — decisions-orphan-tickets CLI**
|
||||
- `tickets.decision_ref` is free-text — a typo'd D-ID or renumbered decision silently orphans tickets.
|
||||
- Build a CLI tool that scans all tickets with a `decision_ref`, validates each against `decisions/*.md`, and reports orphans.
|
||||
- Output: list of tickets pointing at nonexistent or mismatched D-IDs.
|
||||
|
||||
**#888 — meta.schema_version to monotonic semver**
|
||||
- Current `meta.schema_version` stores a SHA-1 of `server/data/systems-schema.sql`. Two SHAs can't be ordered — you can't tell which is newer.
|
||||
- Switch to a monotonic semver string (e.g. `1.0.0`, `1.1.0`). This enables future savegame migration lineage: a save file can record its schema version and determine what migrations to apply.
|
||||
- Update `import_economics.py` stamp logic and `tooling/check-systems-db-stamp` validation.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#887 (orphan-tickets CLI) → standalone
|
||||
#888 (schema_version semver) → standalone
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI:
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "chore(ci): sprint 38 ci" --description "body" --base main --head sprint-38/ci
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# Sprint 38: Depth — Client Tasks
|
||||
|
||||
**Goal:** Map the generation pipeline from planetary heightmaps to walkable tile environments, formally park character/apartment work behind the full cascade, and close test/infra debt from Sprint 37.
|
||||
|
||||
**Branch:** `sprint-38/client`
|
||||
**Agents:** Stig (dev), Tyre (arch), Hoshe (QA)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #898 | Free camera viewer — WASD pan + scroll zoom, implant/atlas/map access | high | — |
|
||||
| #882 | Strip archetype-driven client code | medium | — |
|
||||
| #879 | Revive fog state behavioral tests | medium | — |
|
||||
| #867 | dialogue_box confrontation_monologue signal bug | medium | — |
|
||||
| #871 | Pre-existing test failures — umbrella triage | medium | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-169 (implant UI components), D-170 (HUD visibility)
|
||||
- Implant component library: `client/ui/implant/` (ImplantPanel, ImplantHeader, etc.)
|
||||
- HUD layer manager: `client/scripts/autoloads/hud_groups.gd`
|
||||
|
||||
## Notes
|
||||
|
||||
**#898 — Free camera viewer (bare minimum, throwaway)**
|
||||
- The rendering system is up in the air — this viewer is iterative/disposable. Do not over-engineer.
|
||||
- Current camera is locked to `GameState.player_position` (`client/scripts/autoloads/game_state.gd:18`).
|
||||
- Decouple: add a debug/observer mode where camera position is independent of player entity.
|
||||
- Controls: WASD/arrow key pan, scroll-to-zoom. That's it.
|
||||
- The observer needs access to: implants (D-169/D-170 UI system), atlas, and map. Wire up existing `HudGroups` app paths (`implant/map`, `implant/wiki/gttr`, etc.).
|
||||
- No player entity needed. No server-side observer entity. Just a free camera over whatever tile data exists.
|
||||
- Key files: `client/main.gd` (camera setup), `client/scripts/autoloads/game_state.gd` (player_position), `client/scripts/autoloads/hud_groups.gd` (implant app switching).
|
||||
|
||||
**#882 — Strip archetype client code**
|
||||
- Follow-up to server #878 (done). `CharacterArchetype` trace is Phase 6 filler.
|
||||
- Find all client references to archetype enums/types and remove them.
|
||||
- #878 already removed the server side — client should have no remaining consumers.
|
||||
|
||||
**#879 — Revive fog state behavioral tests**
|
||||
- `test_fog_sprint22.gd` was deleted in Sprint 37 (#870 parse-error cleanup).
|
||||
- Contained unique behavioral tests: `EXP_EXPLORED` persistence after leaving LOS, grow-only bounds invariant.
|
||||
- Rewrite against current `FogState` API. Prefer live Gauntlet testing over mocks per testing preferences.
|
||||
|
||||
**#867 — dialogue_box confrontation signal bug**
|
||||
- `signal_fired` remains false after `_on_option_pressed(0)` on a confrontation option.
|
||||
- `_start_confrontation_beat` likely not firing in headless test mode.
|
||||
- Check signal wiring in `client/ui/dialogue_box.gd`.
|
||||
|
||||
**#871 — Pre-existing test failures umbrella triage**
|
||||
- 13 suites affected. For each: investigate, determine if it's stale assertion or real regression, then either fix inline or create a child ticket.
|
||||
- Per feedback rules: broken tests need a fix or a dated ticket, never a shrug.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#898 (free camera viewer) → standalone, start immediately
|
||||
#882 (strip archetype code) → standalone (#878 done)
|
||||
#879 (fog tests) → standalone
|
||||
#867 (signal bug) → standalone
|
||||
#871 (test triage) → standalone, spawns child tickets
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI:
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): sprint 38 client" --description "body" --base main --head sprint-38/client
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# Sprint 38: Depth — Copy Tasks
|
||||
|
||||
**Goal:** Map the generation pipeline from planetary heightmaps to walkable tile environments, formally park character/apartment work behind the full cascade, and close test/infra debt from Sprint 37.
|
||||
|
||||
**Branch:** `sprint-38/copy`
|
||||
**Agents:** Mellanie (author), Paula (narrative), Gestalt (systems)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #884 | Copy-team review: wiki/corporations authored by server in #860 | medium | — |
|
||||
|
||||
Use `tooling/db/ticket show 884` for full details.
|
||||
|
||||
## Notes
|
||||
|
||||
**#884 — Wiki corporations review**
|
||||
- Sprint 37 server ticket #860 had Dudley author/modify 21 `wiki/corporations/*.md` files to resolve the economy-db coverage gate.
|
||||
- Per team scope rules, `wiki/` is copy-team territory. These files need a voice/lore review pass.
|
||||
- Check: naming consistency, tone/voice alignment with existing wiki style, lore accuracy, factual consistency with economics data in `tooling/economy-db/`.
|
||||
- Light-touch — fix voice issues and flag lore contradictions, don't rewrite from scratch.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#884 (wiki corps review) → standalone
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI:
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "content(wiki): sprint 38 copy" --description "body" --base main --head sprint-38/copy
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
# Sprint 38: Depth — Planning Tasks
|
||||
|
||||
**Goal:** Map the generation pipeline from planetary heightmaps to walkable tile environments, formally park character/apartment work behind the full cascade, and close test/infra debt from Sprint 37.
|
||||
|
||||
**Branch:** `sprint-38/planning`
|
||||
**Agents:** Gestalt (systems), Tyre (technical), Miri (worldbuilding), Qatux (documenter), SI (project manager)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #897 | Generation pipeline cascade — map all layers, produce D-records | high | — |
|
||||
|
||||
Use `tooling/db/ticket show 897` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-110 (signed z-levels), D-108 (MobileChunk)
|
||||
- `decisions/scope.md` — development cascade phases (CLAUDE.md §Development Cascade)
|
||||
|
||||
## Notes
|
||||
|
||||
**#897 — Generation pipeline cascade D-records**
|
||||
|
||||
This is the load-bearing planning ticket for the sprint. The team keeps cycling back to character and apartment topics before the generation pipeline is complete. This ticket produces the authoritative reference that prevents that.
|
||||
|
||||
**What exists:**
|
||||
- Galactic → system → planetary heightmaps: done (`tooling/planet-gen/generate_atlas.py`, `planet_simulation.py`)
|
||||
- Atlas markers with city placement, roads, rail, rivers: done (`markers.json` per body)
|
||||
- Generator data model in `server/src/simulation/generator.rs`: complete type hierarchy (DistrictSkeleton, BlockSkeleton, FloorZone, ChunkLayout, etc.) but ALL generation logic is stubs — no code actually produces filled instances
|
||||
- `server/src/simulation/chunk_streaming.rs`: chunk load/unload system exists but has nothing to load
|
||||
- `server/src/bin/generator_spike.rs`: spike binary, likely exploratory
|
||||
|
||||
**What this ticket must deliver:**
|
||||
1. A complete map of every generation layer from planetary heightmap to walkable tile environment: what the layer is, what generates it, what its inputs and outputs are, what exists vs. what's missing
|
||||
2. D-records in `decisions/` for each layer — at minimum one D-record defining the full pipeline, possibly per-layer records if they're complex enough
|
||||
3. Ticket dependency chain: create tickets for missing pipeline layers and set up `ticket_deps` so that character work (#694, #619) and apartment work (#681, #682) are explicitly blocked by the generation pipeline tickets
|
||||
4. Update existing stale tickets that reference premature work (e.g. #615 tycoon starting state, #616 economic verb vocabulary) — either re-scope them behind generation pipeline blockers or defer them with a note
|
||||
|
||||
**Discussion structure:**
|
||||
- Round 1 (inventory): What exists at each scale? What does each generator produce? Where are the gaps?
|
||||
- Round 2 (proposals): For each gap, what's the minimum viable generator? What are the inputs/outputs? What decisions are needed?
|
||||
- Round 3 (convergence): Lock D-records, create tickets, wire dependencies
|
||||
|
||||
**Output:** D-records in `decisions/`, ticket dependency graph, updated blockers on character/apartment tickets.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#897 (generation cascade D-records) → standalone, blocks everything downstream
|
||||
→ creates blocker tickets for #694, #619, #681, #682
|
||||
→ unblocks #899 (district skeleton server ticket)
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI:
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "plan(scope): generation pipeline cascade D-records" --description "body" --base main --head sprint-38/planning
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# Sprint 38: Depth — Server Tasks
|
||||
|
||||
**Goal:** Map the generation pipeline from planetary heightmaps to walkable tile environments, formally park character/apartment work behind the full cascade, and close test/infra debt from Sprint 37.
|
||||
|
||||
**Branch:** `sprint-38/server`
|
||||
**Agents:** Dudley (dev), Tyre (arch), Hoshe (QA)
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Priority | Blocked by |
|
||||
|---|-------|----------|------------|
|
||||
| #899 | District skeleton generator — Phase 1 implementation | high | #897 |
|
||||
| #885 | Multiple baseline tests panic with Bevy Resource-does-not-exist | medium | — |
|
||||
| #892 | Expand check-systems-db-stamp to cover naming helpers | medium | — |
|
||||
| #886 | Generator polish: suffix monotony auto-fix + cultural-history prompting | low | — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-110 (signed z-levels), D-108 (MobileChunk)
|
||||
- Generator data model: `server/src/simulation/generator.rs` defines the full type hierarchy
|
||||
|
||||
## Notes
|
||||
|
||||
**#885 — Bevy baseline panics (fix first)**
|
||||
- 6 tests fail on clean main with `Resource<X> does not exist` panics. Confirmed not a sprint regression — present at commit f0465e40.
|
||||
- @hoshe assigned. This is the first ticket to close — other server work should not land on a broken test baseline.
|
||||
- Likely missing `.init_resource::<T>()` or `.insert_resource(T::default())` calls in test setup.
|
||||
|
||||
**#892 — Stamp expansion for naming helpers**
|
||||
- `tooling/check-systems-db-stamp` only tracks `generate_atlas.py` source. The naming pipeline (`gemma_naming.py`, `naming_core.py`) is not covered — changes to naming helpers won't trigger stale-stamp detection.
|
||||
- Add these files to the `GENERATOR_SOURCES` dict in `tooling/check-systems-db-stamp`.
|
||||
- Mirror the change in the `/pr-push` skill's source-file watch list.
|
||||
|
||||
**#886 — Generator polish (suffix monotony + cultural-history)**
|
||||
- Follow-up to Sprint 37 #853. Two partial items remain:
|
||||
- §3 suffix monotony: `gemma_naming.py` detects clustering but doesn't auto-fix. Add retry logic.
|
||||
- §6 cultural-history prompting: explicit history context in the few-shot prompt for richer names.
|
||||
|
||||
**#899 — District skeleton generator Phase 1 (BLOCKED by #897)**
|
||||
- DO NOT start until the planning ticket #897 closes and the generation cascade D-records exist.
|
||||
- The data model in `server/src/simulation/generator.rs` is complete: `DistrictSkeleton`, `BlockSkeleton`, `BlockPlacement`, `FloorZone`, `ZonePalette`, etc. All generation logic is stubs.
|
||||
- Phase 1 scope: given a city entry from `markers.json` + `planet_class` + economy node, produce a filled `DistrictSkeleton` with real block assignments, setting types, zone palettes.
|
||||
- No chunk-level tile generation yet — skeleton structure only.
|
||||
- Key integration: `chunk_streaming.rs` consumes chunk data. The skeleton feeds into chunk fill (Phase 2, future sprint).
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#885 (Bevy panics) → standalone, fix first
|
||||
#892 (stamp expansion) → standalone
|
||||
#886 (generator polish) → standalone
|
||||
#899 (district skeleton) → blocked by #897 (planning)
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI:
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): sprint 38 server" --description "body" --base main --head sprint-38/server
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: "Workshop Brief"
|
||||
description: "Audit the generation pipeline implementation state, map all layers from heightmap to walkable tile, produce D-records and ticket dependency flows"
|
||||
type: workshop
|
||||
status: active
|
||||
workshop: generation-cascade
|
||||
agent: ""
|
||||
round: 0
|
||||
created: 2026-04-24
|
||||
---
|
||||
|
||||
# Generation Cascade Workshop Brief
|
||||
|
||||
**Goal:** Audit the full generation pipeline from planetary heightmaps to walkable tile environments. For each layer: document what exists, what's stub, and what's missing. Produce D-records and a ticket dependency chain that formally blocks character and apartment work behind the complete pipeline.
|
||||
|
||||
**Ticket:** #897
|
||||
**Priority:** HIGH — load-bearing for Sprint 38 and all downstream Phase 4/5 work
|
||||
**Participants:** Gestalt (systems design), Tyre (architecture/feasibility), Miri (worldbuilding/cultural inputs)
|
||||
**Source:** Lead directive (2026-04-24): "we keep cycling back to these topics. I want them parked behind the full cascade from now on."
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The generator-architecture workshop (2026-02-27) designed the full data model: DistrictSkeleton, BlockSkeleton, ChunkData, two-phase generation (Phase 1 skeleton, Phase 2 chunk fill). The outcomes are at `docs/workshops/generator-architecture/workshop-outcomes.md`.
|
||||
|
||||
**The architecture is designed. The implementation is mostly stubs.** The Rust types in `server/src/simulation/generator.rs` compile but no code actually produces filled instances. The team keeps drifting to character creation, apartment generators, and tycoon starting states — all of which sit several pipeline layers below what's actually built.
|
||||
|
||||
This workshop is NOT a redesign. It's an implementation audit and cascade formalization.
|
||||
|
||||
### What exists (confirmed working)
|
||||
|
||||
| Layer | What | Status |
|
||||
|-------|------|--------|
|
||||
| Galactic | System definitions, star data | Done — `systems.db`, wiki |
|
||||
| System | Body definitions, orbital mechanics | Done — `systems.db` |
|
||||
| Planetary surface | Heightmaps, terrain simulation | Done — `tooling/planet-gen/planet_simulation.py` |
|
||||
| Atlas markers | City placement, roads, rail, rivers, mountains | Done — `tooling/planet-gen/generate_atlas.py`, `markers.json` |
|
||||
| City naming | Gemma-driven cultural naming | Done — `tooling/planet-gen/gemma_naming.py` |
|
||||
| Economics | Supply chains, corporations, brands, trade flows | Done — `tooling/economy-db/`, `systems.db` |
|
||||
|
||||
### What exists as types only (stubs, no generation logic)
|
||||
|
||||
| Layer | What | Location |
|
||||
|-------|------|----------|
|
||||
| District skeleton | `DistrictSkeleton`, `BlockSkeleton`, enums | `server/src/simulation/generator.rs` |
|
||||
| Chunk streaming | Load/unload system | `server/src/simulation/chunk_streaming.rs` |
|
||||
| Triangle system | `TriangleAssignment`, `TrianglePurpose` | `server/src/simulation/triangle.rs` |
|
||||
|
||||
### What's missing entirely
|
||||
|
||||
This is what the workshop must map. Suspected gaps include:
|
||||
- Regional/continental subdivision (between heightmap and city)
|
||||
- City-to-district decomposition (how does a city marker become N districts?)
|
||||
- District skeleton generation (the actual Phase 1 code)
|
||||
- Block fill / zoning assignment
|
||||
- Chunk tile generation (Phase 2)
|
||||
- Infrastructure placement within districts (roads, utilities at local scale)
|
||||
- Vertical structure generation (multi-floor buildings)
|
||||
|
||||
---
|
||||
|
||||
## Key Questions to Resolve
|
||||
|
||||
### 1. Pipeline Inventory (all participants)
|
||||
|
||||
For each layer from planetary heightmap to walkable tile:
|
||||
- What is the layer's input and output?
|
||||
- What code/data exists today?
|
||||
- What's the minimum viable implementation?
|
||||
- What decisions from the generator-architecture workshop apply?
|
||||
|
||||
### 2. Layer Dependencies (Tyre)
|
||||
|
||||
- What is the strict dependency order? Which layers can be parallelized?
|
||||
- Where are the data format boundaries (file vs. runtime, Python vs. Rust)?
|
||||
- What's the testing strategy per layer? Can each layer be validated independently?
|
||||
|
||||
### 3. Cultural and Worldbuilding Inputs (Miri)
|
||||
|
||||
- At which layers do cultural inputs (society profiles, naming, architectural style) enter the pipeline?
|
||||
- What wiki/content data is needed before each layer can generate?
|
||||
- Are there content gaps that block generation even if the code existed?
|
||||
|
||||
### 4. System Interactions (Gestalt)
|
||||
|
||||
- How does each generation layer interact with the economics layer?
|
||||
- Where do social sites, NPC population, and zone palettes enter?
|
||||
- What's the minimum viable "viewable world" — the thinnest vertical slice from heightmap to rendered tiles?
|
||||
|
||||
### 5. Ticket Dependency Chain (all participants)
|
||||
|
||||
- What tickets exist for missing layers? What new tickets are needed?
|
||||
- What is the formal dependency chain that blocks character work (#694, #619) and apartment work (#681, #682)?
|
||||
- Which existing tickets (#615, #616) should be re-scoped or deferred?
|
||||
|
||||
---
|
||||
|
||||
## Workshop Format
|
||||
|
||||
**3 rounds:**
|
||||
|
||||
### Round 1 — Inventory
|
||||
Each participant audits the pipeline from their domain perspective. List every layer, its state (done / stub / missing), inputs, outputs, and the key file paths. Write findings to `docs/workshops/generation-cascade/{agent}-round1.md`.
|
||||
|
||||
### Round 2 — Proposals
|
||||
Based on the combined inventory, propose: the ordered implementation plan, the ticket dependency graph, and the D-records needed. Identify the thinnest vertical slice that produces viewable output. Write proposals to `docs/workshops/generation-cascade/{agent}-round2.md`.
|
||||
|
||||
### Round 3 — Convergence
|
||||
Lock the D-records, finalize the ticket dependency chain, and produce the formal blockers. Each participant reviews the proposed D-records and flags disagreements. Write final positions to `docs/workshops/generation-cascade/{agent}-round3.md`.
|
||||
|
||||
---
|
||||
|
||||
## Required Reading
|
||||
|
||||
Before Round 1, all participants must read:
|
||||
- `docs/workshops/generator-architecture/workshop-outcomes.md` — the designed architecture
|
||||
- `server/src/simulation/generator.rs` — the current data model (stubs)
|
||||
- `server/src/simulation/chunk_streaming.rs` — chunk load/unload system
|
||||
- `tooling/planet-gen/generate_atlas.py` — what the atlas generator produces
|
||||
- `decisions/architecture.md` — D-110 (signed z-levels), D-108 (MobileChunk)
|
||||
- CLAUDE.md §Development Cascade — the phase definitions
|
||||
|
||||
---
|
||||
|
||||
## Expected Outputs
|
||||
|
||||
1. **D-record(s)** in `decisions/` defining the generation pipeline layers, their order, and their implementation status
|
||||
2. **Ticket dependency graph** — new tickets for missing layers, `ticket_deps` entries blocking character/apartment work
|
||||
3. **Updated existing tickets** — #615, #616, #619, #681, #682, #694 re-scoped or formally blocked
|
||||
4. **Implementation priority order** — which layer to build next (informs #899 and future sprints)
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.36
|
||||
version: 0.2.0
|
||||
repository: settled-reach
|
||||
|
||||
|
||||
|
||||
Generated
+3
-1
@@ -1294,11 +1294,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.36"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
"bevy_tasks",
|
||||
"bytemuck",
|
||||
"clap",
|
||||
"crossbeam-channel",
|
||||
"econ-sim",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.36"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -28,7 +28,9 @@ crossbeam-channel = "0.5"
|
||||
sysinfo = "0.35"
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
bytemuck = "1"
|
||||
toml = "0.8"
|
||||
aho-corasick = "1"
|
||||
# Economics simulation — Leontief + tâtonnement + D-180 event port (#821)
|
||||
econ-sim = { path = "../tooling/econ-sim" }
|
||||
|
||||
|
||||
@@ -1,692 +0,0 @@
|
||||
# yaml-language-server: $schema=../../schemas/drama_module.schema.yaml
|
||||
#
|
||||
# Tier 1 Drama Module: The Smuggling Ring (v0.1)
|
||||
# The vertical slice Tier 1 module for D-027.
|
||||
#
|
||||
# NARRATIVE CORE:
|
||||
# A logistics worker (the smuggler PC, if played) is embedded in a small ring
|
||||
# smuggling unlicensed lattice components through Sova Transit District (D-037).
|
||||
# The ring is led by Voss from The Terminal. Kael Davan — a ring member and the
|
||||
# smuggler's FRIEND — is quietly trying to exit to protect his partner Naia Tamm.
|
||||
# Sera Venn (the detective's FRIEND) has noticed Kael's manifest discrepancies
|
||||
# but hasn't reported them, protecting Naia by proxy.
|
||||
#
|
||||
# DUAL-LENS EXPERIENCE:
|
||||
# Smuggler plays INSIDE the ring: manage drops, cover tracks, notice Kael going cold.
|
||||
# Detective plays OUTSIDE: cargo anomalies → follow Kael → witness secret meeting →
|
||||
# confront or protect.
|
||||
#
|
||||
# SUCCESS CRITERIA (D-027):
|
||||
# #1: 30 minutes of daily-life play before the ring activates (min_play_ticks: 2100)
|
||||
# #3: Player names Kael as an NPC they felt conflicted about
|
||||
# #4: observe→notice→follow→discover emerges from systems, not scripts
|
||||
|
||||
module_id: smuggling_ring_v0_1
|
||||
display_name: "The Smuggling Ring"
|
||||
version: "0.1"
|
||||
tier: 1
|
||||
description: >
|
||||
A small ring of logistics workers smuggling unlicensed lattice components through
|
||||
Sova Transit District. The ring's weakest link — Kael Davan — is trying to exit
|
||||
to protect his partner. The detective investigates cargo anomalies. The smuggler
|
||||
manages ring operations and navigates Kael's loyalty crisis. Neither character
|
||||
knows the other's full picture until confrontation forces it.
|
||||
|
||||
notes: >
|
||||
This module IS the vertical slice (D-027). It exercises every system at full depth:
|
||||
dual-lens NPC observation, tell progression, trust-gated dialogue, knowledge graph
|
||||
confidence accumulation, confrontation weight (D-063), walk-away consequences (D-064),
|
||||
and THE FRIEND contradiction arc (D-034). All outcome paths must feel earned.
|
||||
No outcome is "the right answer" — Kael's situation has no clean resolution.
|
||||
|
||||
dual_lens:
|
||||
smuggler: >
|
||||
You're inside the ring. Voss manages operations; you handle logistics cover.
|
||||
Kael used to be reliable. Lately he's absent, distracted, making excuses.
|
||||
The drop schedule is at risk. Do you pressure him, cover for him, or cut him?
|
||||
You don't know he's trying to get out. He doesn't know you've noticed.
|
||||
detective: >
|
||||
Cargo manifest discrepancies in The Terminal. Small, systematic, deniable.
|
||||
Your analytical lattice flags them before your conscious mind does.
|
||||
Follow the thread: discrepancy → dock worker with odd schedule → Kael Davan →
|
||||
maintenance corridors → someone he shouldn't be meeting. And then what?
|
||||
Arrest a man trying to leave a ring he never wanted to join?
|
||||
|
||||
pool:
|
||||
weight: 8
|
||||
compatible_districts:
|
||||
- sova-transit
|
||||
max_concurrent: 1
|
||||
|
||||
# ── ENTRY CONDITIONS ─────────────────────────────────────────────────────────
|
||||
# Ring activity begins after player has had time to establish routine (D-027 #1).
|
||||
# The ring is already running at game start — the module activates when the
|
||||
# storyteller decides the tension has built enough to surface.
|
||||
|
||||
entry_conditions:
|
||||
world_state:
|
||||
- type: npc_present
|
||||
role: ring-leader
|
||||
- type: npc_present
|
||||
role: ring-member-exiting
|
||||
- type: location_accessible
|
||||
location: the-terminal
|
||||
- type: location_accessible
|
||||
location: maintenance-corridors
|
||||
|
||||
activation:
|
||||
trigger: storyteller_push
|
||||
min_play_ticks: 2100 # ~35 minutes at 1 tick/second — D-027 criterion #1
|
||||
# The storyteller pushes activation when player has established presence
|
||||
# in The Terminal or The Last Shift through routine interaction.
|
||||
# Proximity trigger (maintenance-corridors) is a secondary activation path
|
||||
# if the player wanders there early.
|
||||
|
||||
# ── NPC REQUIREMENTS ─────────────────────────────────────────────────────────
|
||||
# All core roles are named (hand-authored NPCs from the vertical slice).
|
||||
# No generated NPC slots in v0.1 — the smuggling ring uses the 15 authored NPCs.
|
||||
|
||||
npc_requirements:
|
||||
- role: ring-leader
|
||||
display_hint: >
|
||||
Runs the ring from The Terminal. Logistics authority = cover.
|
||||
Never handles contraband directly. Pressure source for Kael.
|
||||
binding: named
|
||||
named_npc: "npc:voss"
|
||||
must_have_motivation: HANDLER
|
||||
|
||||
- role: ring-member-exiting
|
||||
display_hint: >
|
||||
Kael Davan. Dock worker, ring member, smuggler's FRIEND.
|
||||
Trying to exit quietly to protect Naia. This is THE FRIEND contradiction.
|
||||
Every event sequence runs through this role.
|
||||
binding: named
|
||||
named_npc: "npc:kael-davan"
|
||||
must_have_pattern: FRIEND
|
||||
must_have_motivation: TURNCOAT
|
||||
|
||||
- role: partner-uninvolved
|
||||
display_hint: >
|
||||
Naia Tamm. Kael's partner. Does not know about the ring.
|
||||
Her safety is Kael's motivation for exiting. Her ignorance is the moral weight.
|
||||
Discovery of her connection to Kael is a late-investigation revelation.
|
||||
binding: named
|
||||
named_npc: "npc:naia-tamm"
|
||||
must_have_motivation: CIVILIAN
|
||||
|
||||
- role: evidence-holder
|
||||
display_hint: >
|
||||
Sera Venn. Detective's FRIEND. Commission field tech.
|
||||
She has noticed Kael's manifest discrepancies but hasn't reported them —
|
||||
she knows Naia, and filing means Kael's arrest and Naia's exposure.
|
||||
Her silence IS the detective's investigation blocker in phase 1.
|
||||
binding: named
|
||||
named_npc: "npc:sera-venn"
|
||||
must_have_pattern: FRIEND
|
||||
must_have_motivation: WITNESS
|
||||
|
||||
- role: ring-operative
|
||||
display_hint: >
|
||||
The ring's operational member in maintenance corridors.
|
||||
Handles physical drops. Not a speaking character — observable behavior only.
|
||||
Can be the anonymous contact Kael meets.
|
||||
binding: named
|
||||
named_npc: "npc:nils-davan"
|
||||
is_optional: false
|
||||
|
||||
- role: institutional-watcher
|
||||
display_hint: >
|
||||
Maret Korr. A Commission observer embedded at The Terminal.
|
||||
Her growing attention is the external pressure that accelerates the timeline.
|
||||
She doesn't know about the ring specifically — she's tracking cargo patterns.
|
||||
binding: named
|
||||
named_npc: "npc:maret-korr"
|
||||
must_have_motivation: OPERATOR
|
||||
is_optional: true # Module runs without Maret, but with degraded tension arc
|
||||
|
||||
# ── EVENTS ───────────────────────────────────────────────────────────────────
|
||||
# Two sequences + one pool.
|
||||
# Sequence A: Kael's exit arc (the FRIEND contradiction backbone)
|
||||
# Sequence B: Investigation pressure arc (escalating discovery opportunities)
|
||||
# Pool: ambient ring activity (fires opportunistically throughout the module)
|
||||
|
||||
events:
|
||||
|
||||
sequences:
|
||||
|
||||
# SEQUENCE A: Kael's Exit Arc
|
||||
# The narrative spine. Each step makes Kael's contradiction more visible.
|
||||
# Observable to both characters, interpreted differently.
|
||||
|
||||
- sequence_id: kael_exit_arc
|
||||
label: "Kael's Exit Arc"
|
||||
description: >
|
||||
Kael Davan's progressive attempt to leave the ring.
|
||||
Tells intensify. Routine deviations appear. The secret meeting is the
|
||||
pivot point — after it fires, both characters' understanding shifts.
|
||||
steps:
|
||||
|
||||
- event_id: kael_goes_cold
|
||||
label: "Kael Goes Cold"
|
||||
description: >
|
||||
Kael starts missing social patterns he'd normally keep — fewer bar visits,
|
||||
shorter responses at The Terminal, leaving early. His tell system activates:
|
||||
the shoulder-check behavior appears. Nothing dramatic. Just absence where
|
||||
there was presence. The smuggler notices because they work together.
|
||||
The detective might notice if they've been tracking Kael's baseline.
|
||||
triggers:
|
||||
- type: ticks_since_activation
|
||||
ticks: 300 # ~5 minutes after module activates
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael skips his usual post-shift drink at The Last Shift.
|
||||
Leaves the terminal 15 minutes early. No explanation.
|
||||
- type: tell_intensify
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael's shoulder-check behavior activates at The Terminal.
|
||||
Visible to any character with forward vision cone in his direction.
|
||||
sets_flag: kael_behavior_changed
|
||||
|
||||
- event_id: drop_happens_without_kael
|
||||
label: "Scheduled Drop — Kael Absent"
|
||||
description: >
|
||||
A ring drop occurs in maintenance corridor C-7. Kael was supposed
|
||||
to verify the cargo. He wasn't there. Nils covered it.
|
||||
The smuggler notices the irregularity in the paperwork.
|
||||
The detective — if watching cargo patterns — sees a manifest entry
|
||||
with no verifying signature where one is normally present.
|
||||
triggers:
|
||||
- type: ticks_since_event
|
||||
after_event: kael_goes_cold
|
||||
ticks: 450 # ~7.5 minutes after goes-cold
|
||||
effects:
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.kael_missed_verification"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Smuggler: check the cargo manifest in The Terminal office.
|
||||
Detective: analytical lattice flags unsigned verification entry.
|
||||
- type: location_state
|
||||
location: maintenance-corridors
|
||||
description: "An unsigned cargo verification entry exists in corridor C-7's log."
|
||||
sets_flag: kael_missed_drop
|
||||
|
||||
- event_id: kael_secret_meeting
|
||||
label: "Kael's Secret Meeting"
|
||||
description: >
|
||||
Kael meets an off-district contact in maintenance corridor B-7.
|
||||
This is the observable contradiction (D-034): Kael, in a restricted
|
||||
area he has no logged reason to be in, talking to someone who's
|
||||
not in any district NPC roster. His body language is tense.
|
||||
If the player is in visual range: this is the pivot moment.
|
||||
If not: the meeting happens anyway — the world doesn't wait.
|
||||
triggers:
|
||||
- type: ticks_since_event
|
||||
after_event: drop_happens_without_kael
|
||||
ticks: 600 # ~10 minutes after the dropped verification
|
||||
- type: player_proximity
|
||||
target_type: location
|
||||
target: maintenance-corridors
|
||||
radius_tiles: 12 # Player wandering near triggers the meeting early
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael enters maintenance corridor B-7. Locked door to restricted
|
||||
supply closet. Emerges with the ring-operative 8 minutes later.
|
||||
Neither acknowledges the encounter publicly.
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.kael_unauthorized_corridor_access"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Player must be in visual range of corridor B-7.
|
||||
Or examine the corridor door access log (investigative action).
|
||||
- type: tell_intensify
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
After the meeting, Kael's shoulder-check frequency doubles.
|
||||
Also: he avoids eye contact with the smuggler at The Terminal.
|
||||
sets_flag: secret_meeting_occurred
|
||||
|
||||
- event_id: kael_sends_message
|
||||
label: "Kael Sends the Message"
|
||||
description: >
|
||||
Kael sends an encrypted Meridian message to an off-district contact.
|
||||
The detective's analytical lattice can detect an anomalous outgoing
|
||||
packet from the district node — not the content, just the pattern
|
||||
(frequent, encrypted, sent from cargo bay terminals, not personal devices).
|
||||
The smuggler won't see this unless they're specifically watching Kael.
|
||||
triggers:
|
||||
- type: ticks_since_event
|
||||
after_event: kael_secret_meeting
|
||||
ticks: 200
|
||||
effects:
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.kael_unusual_meridian_activity"
|
||||
discoverable_by: detective
|
||||
discovery_method: >
|
||||
Detective's analytical lattice flags the outgoing packet pattern.
|
||||
Requires player to be in or adjacent to The Terminal cargo bay.
|
||||
sets_flag: kael_message_sent
|
||||
|
||||
- event_id: ring_leader_confronts_kael
|
||||
label: "Voss Confronts Kael"
|
||||
description: >
|
||||
Voss calls Kael into The Terminal supervisor's office.
|
||||
Closed door. Raised voices (audible only from adjacent room/position).
|
||||
Kael emerges pale. Voss emerges neutral. The smuggler can witness
|
||||
the approach/departure without hearing content. The detective can
|
||||
observe Kael's state immediately after if in The Terminal.
|
||||
This is Voss applying pressure. Kael is now visibly under strain.
|
||||
triggers:
|
||||
- type: flag_set
|
||||
flag: kael_message_sent
|
||||
- type: ticks_since_event
|
||||
after_event: kael_message_sent
|
||||
ticks: 400
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-leader
|
||||
description: "Voss calls Kael into the supervisor's office. Door closed."
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael emerges from the meeting looking strained. His shoulder-check
|
||||
is now constant. He takes an unscheduled break outside, alone.
|
||||
- type: tell_intensify
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael's contentment hits lowest observed level. He now actively avoids
|
||||
the ring-operative (Nils) in public. The disconnection is visible.
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.voss_kael_private_meeting"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Observe the meeting room door (spatial). Or ask Kael directly
|
||||
after (trust-gated dialogue unlocks "Are you alright?" option).
|
||||
sets_flag: voss_pressure_applied
|
||||
|
||||
# SEQUENCE B: Investigation Pressure Arc
|
||||
# External pressure that escalates the timeline.
|
||||
# Fires in parallel with Sequence A.
|
||||
|
||||
- sequence_id: investigation_pressure
|
||||
label: "Investigation Pressure Arc"
|
||||
description: >
|
||||
Maret Korr's institutional attention creates a closing window.
|
||||
Her growing interest is the reason the module can't stay in equilibrium forever.
|
||||
She doesn't know about the ring — she's a pattern-watcher. But patterns
|
||||
are what the detective investigates too. Their paths converge.
|
||||
steps:
|
||||
|
||||
- event_id: maret_flags_anomaly
|
||||
label: "Maret Flags the Cargo Anomaly"
|
||||
description: >
|
||||
Maret Korr files an internal Commission note flagging The Terminal's
|
||||
cargo variance rate as statistically unusual. Not an investigation —
|
||||
just a flag. The detective's institutional access can pull this note.
|
||||
The smuggler has no way to know it exists (unless the detective tells them).
|
||||
triggers:
|
||||
- type: ticks_since_activation
|
||||
ticks: 900 # ~15 minutes after activation
|
||||
effects:
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.commission_cargo_flag"
|
||||
discoverable_by: detective
|
||||
discovery_method: >
|
||||
Detective queries Commission data via institutional access
|
||||
(authority access tier, Terminal records).
|
||||
sets_flag: commission_flag_exists
|
||||
|
||||
- event_id: maret_increases_presence
|
||||
label: "Maret Increases Her Presence"
|
||||
description: >
|
||||
Maret starts spending more time in The Terminal. More frequent
|
||||
walkthroughs during shift changes. Her attention to the cargo bay
|
||||
area is noticeable to anyone watching. Ring members are unnerved.
|
||||
Voss starts accelerating the timeline to close operations before
|
||||
institutional attention becomes formal investigation.
|
||||
triggers:
|
||||
- type: ticks_since_event
|
||||
after_event: maret_flags_anomaly
|
||||
ticks: 600
|
||||
- type: player_action
|
||||
action: examine
|
||||
target_role: institutional-watcher
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: institutional-watcher
|
||||
description: >
|
||||
Maret adds two extra Terminal walkthroughs per shift cycle.
|
||||
Spends 15 minutes studying the cargo bay manifest terminals.
|
||||
- type: tell_intensify
|
||||
npc_role: ring-leader
|
||||
description: >
|
||||
Voss becomes quieter, more deliberate. Less casual conversation.
|
||||
His tell — the stillness before speaking — becomes more frequent.
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.voss_accelerating_timeline"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Smuggler: Voss mentions "the schedule moving up" in a guarded
|
||||
conversation (trust-gated, ring-insider access required).
|
||||
Detective: observe Voss and Nils in two exchanges within same shift.
|
||||
sets_flag: timeline_accelerating
|
||||
|
||||
- event_id: final_shipment_scheduled
|
||||
label: "The Final Shipment Is Scheduled"
|
||||
description: >
|
||||
The ring schedules the last major drop — after this, they go dark.
|
||||
This is the closing window. If the detective hasn't uncovered enough
|
||||
by the time this fires, the ring disperses and the operation closes
|
||||
without exposure (escaped outcome). If they have, confrontation
|
||||
becomes unavoidable. The smuggler knows about this drop. Kael doesn't
|
||||
want to participate. Voss insists.
|
||||
triggers:
|
||||
- type: flag_set
|
||||
flag: timeline_accelerating
|
||||
- type: ticks_since_event
|
||||
after_event: maret_increases_presence
|
||||
ticks: 800
|
||||
effects:
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.final_shipment_scheduled"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Smuggler: direct notification from Voss.
|
||||
Detective: cargo manifest shows an unusual large entry for 3 days out.
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-member-exiting
|
||||
description: >
|
||||
Kael's schedule changes: he's assigned to the cargo bay
|
||||
during the drop window. He doesn't want to be there.
|
||||
sets_flag: final_shipment_known
|
||||
|
||||
pools:
|
||||
|
||||
# POOL: Ambient ring activity — opportunistic events that add texture
|
||||
- pool_id: ambient_ring_activity
|
||||
label: "Ambient Ring Activity"
|
||||
description: >
|
||||
Low-level ring business that happens throughout the module regardless of
|
||||
player engagement. Creates the sense that the ring exists independently.
|
||||
Players who look closely will find more; players who don't still feel the world moving.
|
||||
events:
|
||||
- event_id: cargo_discrepancy_appears
|
||||
label: "Small Cargo Discrepancy Appears"
|
||||
description: >
|
||||
A minor manifest irregularity appears in The Terminal records.
|
||||
Small enough to be deniable. Systematic enough to be a pattern.
|
||||
The detective's analytical lattice may flag it. The smuggler can
|
||||
correct it if they notice it — covering tracks is part of their role.
|
||||
triggers:
|
||||
- type: ticks_since_activation
|
||||
ticks: 150 # Fires early and repeats
|
||||
effects:
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.cargo_discrepancy_pattern"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Detective: analytical lattice flags during Terminal walkthrough.
|
||||
Smuggler: check manifest terminals (or get flagged by the discrepancy
|
||||
in their own work).
|
||||
once: false # Repeats — pattern builds over time
|
||||
|
||||
- event_id: sera_avoids_torek
|
||||
label: "Sera Avoids Torek at The Bar"
|
||||
description: >
|
||||
Sera Venn reroutes her usual path through The Last Shift to avoid
|
||||
standing near Torek Lintar (the Commission enforcement officer).
|
||||
Anyone watching Sera's normal pattern would notice.
|
||||
This is the detective's first clue that Sera's behavior is odd.
|
||||
triggers:
|
||||
- type: ticks_since_activation
|
||||
ticks: 500
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: evidence-holder
|
||||
description: >
|
||||
Sera takes a longer route to her usual seat, passing through
|
||||
the back of the bar to avoid Torek's sightline.
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.sera_avoidance_behavior"
|
||||
discoverable_by: detective
|
||||
discovery_method: >
|
||||
Observe Sera's path through the bar on two separate occasions.
|
||||
Requires forward vision cone and awareness of her baseline route.
|
||||
once: false
|
||||
|
||||
- event_id: nils_makes_supply_run
|
||||
label: "Nils Makes an Unscheduled Supply Run"
|
||||
description: >
|
||||
The ring-operative (Nils) enters the maintenance corridors with a
|
||||
small container logged as "calibration tools". The container isn't
|
||||
logged for return. Someone paying attention to cargo flow would notice.
|
||||
triggers:
|
||||
- type: ticks_since_activation
|
||||
ticks: 700
|
||||
effects:
|
||||
- type: npc_routine_deviation
|
||||
npc_role: ring-operative
|
||||
description: "Nils takes a container to maintenance corridor C-7."
|
||||
- type: fact_becomes_discoverable
|
||||
fact_id: "ring.nils_unlogged_cargo"
|
||||
discoverable_by: any
|
||||
discovery_method: >
|
||||
Watch Nils's cargo handling pattern over two shifts.
|
||||
Or examine maintenance corridor C-7 access log.
|
||||
once: false
|
||||
|
||||
# ── OUTCOMES ─────────────────────────────────────────────────────────────────
|
||||
# Five resolution states. Checked each tick after the first sequence step fires.
|
||||
# Order matters — the storyteller applies the first matching outcome.
|
||||
# is_terminal: true ends the module.
|
||||
|
||||
outcomes:
|
||||
|
||||
# 1. RING EXPOSED
|
||||
# Detective successfully uncovers the operation.
|
||||
# Commission becomes involved. Arrests/flight follow.
|
||||
- outcome_id: ring_exposed
|
||||
label: "Ring Exposed"
|
||||
is_terminal: true
|
||||
description: >
|
||||
The detective accumulates enough evidence to trigger a formal Commission
|
||||
inquiry. The ring collapses: arrests, flight, or both. Voss is detained.
|
||||
Kael's situation is now public. The smuggler (if played) faces consequences.
|
||||
Naia learns what Kael was doing — and why he was trying to leave.
|
||||
No clean endings. The right outcome for the detective who goes all the way.
|
||||
conditions:
|
||||
facts_known:
|
||||
- "ring.cargo_discrepancy_pattern"
|
||||
- "ring.kael_unauthorized_corridor_access"
|
||||
- "ring.voss_kael_private_meeting"
|
||||
flags_set:
|
||||
- secret_meeting_occurred # set by kael_secret_meeting event
|
||||
- commission_flag_exists # Commission was watching before exposure
|
||||
effects:
|
||||
- type: npc_disposition
|
||||
npc_role: ring-leader
|
||||
shift: hostile
|
||||
description: "Voss is detained or flees. Commission inquiry opens."
|
||||
- type: npc_disposition
|
||||
npc_role: ring-member-exiting
|
||||
shift: hostile
|
||||
description: >
|
||||
Kael is arrested or disappears. His exit attempt is now moot.
|
||||
His relationship with Naia is exposed.
|
||||
- type: faction_reaction
|
||||
faction: lattice-commission
|
||||
reaction: grateful
|
||||
description: "Commission credits the detective's investigation."
|
||||
- type: npc_exit
|
||||
npc_role: ring-leader
|
||||
description: "Voss leaves the district — detained, fled, or both."
|
||||
|
||||
# 2. KAEL ESCAPES THE RING
|
||||
# Unique path. Requires the player to engage with Kael directly
|
||||
# and choose to help him rather than expose the ring wholesale.
|
||||
- outcome_id: kael_escapes
|
||||
label: "Kael Escapes the Ring"
|
||||
is_terminal: true
|
||||
description: >
|
||||
Through the player's choices — helping Kael cover his exit, or warning him,
|
||||
or simply choosing not to act on what they know — Kael successfully leaves
|
||||
the ring before the final shipment. He and Naia leave the district quietly.
|
||||
The ring continues without him, smaller and more cautious.
|
||||
This outcome requires discovering Kael's secret AND choosing restraint.
|
||||
The smuggler can engineer this by covering for Kael with Voss.
|
||||
The detective can achieve this by confronting Kael privately rather than
|
||||
filing a report. The most morally complicated path.
|
||||
conditions:
|
||||
facts_known:
|
||||
- "ring.kael_unauthorized_corridor_access"
|
||||
flags_set:
|
||||
- kael_behavior_changed # set by kael_goes_cold — his exit arc begins here
|
||||
- secret_meeting_occurred # set by kael_secret_meeting — the pivot moment
|
||||
- voss_pressure_applied # set by ring_leader_confronts_kael — pressure applied
|
||||
# ring_exposed is checked first in the outcomes list and is terminal,
|
||||
# so kael_escapes only evaluates if ring_exposed hasn't fired.
|
||||
# No flags_not_set needed here — outcome ordering handles priority.
|
||||
effects:
|
||||
- type: npc_disposition
|
||||
npc_role: ring-member-exiting
|
||||
shift: friendly
|
||||
description: "Kael remembers whoever helped him. He's gone, but grateful."
|
||||
- type: npc_exit
|
||||
npc_role: ring-member-exiting
|
||||
description: "Kael and Naia leave Sova Transit District."
|
||||
- type: faction_reaction
|
||||
faction: the-ring
|
||||
reaction: suspicious
|
||||
description: "The ring is destabilized by Kael's exit. Voss is alert to further leaks."
|
||||
|
||||
# 3. RING COMPLETES OPERATION
|
||||
# The ring finishes the final shipment and goes dark before discovery.
|
||||
# Default path if the detective doesn't move fast enough.
|
||||
- outcome_id: ring_completes
|
||||
label: "Ring Completes the Operation"
|
||||
is_terminal: true
|
||||
description: >
|
||||
The final shipment clears. The ring disperses. Voss transfers. Nils goes quiet.
|
||||
Kael stays — he's now out by default, the ring having dissolved around him.
|
||||
The evidence trail goes cold. The detective closes the case as inconclusive.
|
||||
The smuggler completes their last run and waits to see if there's another.
|
||||
Unsatisfying only if you expected a tidy resolution. The world moved on.
|
||||
conditions:
|
||||
flags_set:
|
||||
- final_shipment_known # set by final_shipment_scheduled event
|
||||
- timeline_accelerating # set by maret_increases_presence — Maret forced their hand
|
||||
facts_not_known:
|
||||
- "ring.cargo_discrepancy_pattern" # detective never found the basic pattern — no investigation
|
||||
ticks_since_activation: 3600 # Module ran for ~60 minutes without full exposure
|
||||
# kael_message_sent was previously gated here but auto-fires at tick ~1550,
|
||||
# making this outcome permanently unreachable. Replaced with player-action fact gate.
|
||||
effects:
|
||||
- type: faction_reaction
|
||||
faction: the-ring
|
||||
reaction: neutral
|
||||
description: "The ring successfully completed this operation. They'll be back."
|
||||
- type: npc_exit
|
||||
npc_role: ring-leader
|
||||
description: "Voss transfers to another station for 'career development'."
|
||||
- type: location_access_change
|
||||
location: maintenance-corridors
|
||||
change: open
|
||||
description: "The restricted supply closet is now empty. Access log shows it cleared."
|
||||
|
||||
# 4. RING SPLINTERS
|
||||
# Partial discovery. The ring fractures but doesn't fully collapse.
|
||||
# An incomplete ending that leaves threads for future investigation.
|
||||
- outcome_id: ring_splinters
|
||||
label: "Ring Splinters"
|
||||
is_terminal: false # Not terminal — splinter state can evolve
|
||||
description: >
|
||||
Enough evidence surfaces that the ring knows it's been partially seen.
|
||||
Voss shuts down active operations. Nils disappears. Kael stays — now the
|
||||
one person in the district who knows what happened and has no one to tell.
|
||||
The formal investigation stalls for lack of a clear chain of evidence.
|
||||
The detective has facts but not the complete picture. The smuggler
|
||||
faces an awkward return to normalcy. Both know the ring isn't gone — just quiet.
|
||||
conditions:
|
||||
facts_known:
|
||||
- "ring.cargo_discrepancy_pattern" # detective found some evidence — ring responds
|
||||
events_fired:
|
||||
- kael_goes_cold # event ID — Kael's behavioral shift fired
|
||||
flags_set:
|
||||
- kael_missed_drop # set by drop_happens_without_kael — ring destabilized
|
||||
ticks_since_activation: 2400
|
||||
# Mutually exclusive with ring_completes via facts_known/facts_not_known on
|
||||
# ring.cargo_discrepancy_pattern. No auto-flag gate needed.
|
||||
effects:
|
||||
- type: npc_disposition
|
||||
npc_role: ring-leader
|
||||
shift: suspicious
|
||||
description: "Voss goes quiet. He's watching to see who knows what."
|
||||
- type: npc_exit
|
||||
npc_role: ring-operative
|
||||
description: "Nils stops appearing at The Terminal. Transferred, officially."
|
||||
- type: faction_reaction
|
||||
faction: the-ring
|
||||
reaction: suspicious
|
||||
description: "The ring is alerted to exposure risk. Future operations will be more careful."
|
||||
|
||||
# 5. INVESTIGATION STALLS (post-splinter exit)
|
||||
# The ring splinters but the detective never breaks through to the pivot evidence.
|
||||
# Explicit terminal exit for the non-terminal ring_splinters state.
|
||||
- outcome_id: ring_stalemate
|
||||
label: "Investigation Stalls"
|
||||
is_terminal: true
|
||||
description: >
|
||||
The ring went dark after the splinter. The detective has the cargo discrepancy
|
||||
on record — enough to flag, not enough to pursue. The case stays open but cold.
|
||||
No arrests. No answers. Kael stays in the district, the only person who knows
|
||||
the full shape of what happened, with no one left to tell it to.
|
||||
The ring will reconstitute elsewhere. It always does.
|
||||
conditions:
|
||||
facts_known:
|
||||
- "ring.cargo_discrepancy_pattern" # ring_splinters already fired (same gate)
|
||||
facts_not_known:
|
||||
- "ring.kael_unauthorized_corridor_access" # detective never reached the pivot evidence
|
||||
flags_set:
|
||||
- kael_missed_drop
|
||||
- final_shipment_known # ring finished while investigation stalled
|
||||
ticks_since_activation: 4500 # 2100 ticks after ring_splinters window — investigation ran cold
|
||||
effects:
|
||||
- type: faction_reaction
|
||||
faction: lattice-commission
|
||||
reaction: neutral
|
||||
description: "The discrepancy flag stays in Maret's file. No follow-up action."
|
||||
- type: npc_exit
|
||||
npc_role: ring-leader
|
||||
description: "Voss quietly transfers. No announcement, no incident report."
|
||||
|
||||
# 6. MODULE EXPIRY (quiet exit)
|
||||
# Player never engaged at all. Module times out without drama.
|
||||
# NOTE (Gestalt, Sprint 18): Condition uses facts_not_known, not flags_not_set.
|
||||
# kael_behavior_changed fires automatically at tick 300 (time-triggered), making
|
||||
# flags_not_set: [kael_behavior_changed] permanently false after tick 300.
|
||||
# Gate expiry on player-action-required facts instead.
|
||||
- outcome_id: module_abandoned
|
||||
label: "Module Abandoned"
|
||||
is_terminal: true
|
||||
is_expiry: true
|
||||
description: >
|
||||
The player never engaged with the ring's signals. The final shipment
|
||||
completed without incident. The ring disperses on its own schedule.
|
||||
Kael stays. The world is unchanged. This is not failure — it's the game
|
||||
acknowledging that not every conspiracy needs a protagonist.
|
||||
The 70% mundane majority (D-029) plays out: life continued.
|
||||
conditions:
|
||||
facts_not_known:
|
||||
- "ring.cargo_discrepancy_pattern" # Only known via player examination of terminal
|
||||
- "ring.kael_unauthorized_corridor_access" # Only known via player observing Kael in B-7
|
||||
ticks_since_activation: 5400 # ~90 minutes with zero player investigation
|
||||
effects:
|
||||
- type: faction_reaction
|
||||
faction: the-ring
|
||||
reaction: neutral
|
||||
description: "The ring closed operations without incident. No record of compromise."
|
||||
@@ -1,718 +0,0 @@
|
||||
# Drama Module Schema — Tier 1 Content (D-023)
|
||||
# YAML expression of JSON Schema 2020-12
|
||||
# Validated against this schema: server/content/modules/tier1/*.yaml
|
||||
#
|
||||
# Ownership:
|
||||
# Dramatic structure (this file): Paula
|
||||
# YAML validation tooling / serde structs: Gestalt / Tyre
|
||||
# Authoring ergonomics review: Mellanie
|
||||
#
|
||||
# See: docs/design/tier1-module-authoring.md for field-by-field guide.
|
||||
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema"
|
||||
$id: "drama_module.schema.yaml"
|
||||
title: "Tier 1 Drama Module"
|
||||
description: >
|
||||
A hand-authored drama module drawn from the pool at game start.
|
||||
The storyteller activates one or more modules per playthrough based on
|
||||
entry conditions, then fires events and detects outcomes. Tier 1 modules
|
||||
are the conspiracy layer of D-023 — authored, optional, relocatable.
|
||||
type: object
|
||||
required:
|
||||
- module_id
|
||||
- display_name
|
||||
- version
|
||||
- tier
|
||||
- pool
|
||||
- entry_conditions
|
||||
- npc_requirements
|
||||
- events
|
||||
- outcomes
|
||||
additionalProperties: false
|
||||
|
||||
properties:
|
||||
|
||||
# ── IDENTITY ────────────────────────────────────────────────────────────────
|
||||
|
||||
module_id:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
|
||||
description: >
|
||||
Stable unique slug. Format: {name}_v{major}_{minor}.
|
||||
Never reuse IDs. Increment version on breaking structural changes.
|
||||
Example: "smuggling_ring_v0_1"
|
||||
|
||||
display_name:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: "Human-readable title shown in dev/debug tooling."
|
||||
|
||||
version:
|
||||
type: string
|
||||
pattern: "^[0-9]+\\.[0-9]+$"
|
||||
description: "Authoring version. Semantic: major.minor."
|
||||
|
||||
tier:
|
||||
type: integer
|
||||
const: 1
|
||||
description: "Always 1 for Tier 1 drama modules."
|
||||
|
||||
description:
|
||||
type: string
|
||||
description: "One-paragraph authoring summary. Not shown in-game."
|
||||
|
||||
# ── POOL METADATA ─────────────────────────────────────────────────────────
|
||||
# Controls how the storyteller includes this module in the per-playthrough pool.
|
||||
|
||||
pool:
|
||||
type: object
|
||||
required:
|
||||
- weight
|
||||
additionalProperties: false
|
||||
description: "How the storyteller samples this module from the pool."
|
||||
properties:
|
||||
weight:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 10
|
||||
description: >
|
||||
Relative selection probability (1–10). Higher = more likely to be
|
||||
included in a given playthrough's active module set. Default: 5.
|
||||
compatible_districts:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
District slugs where this module can activate, or omit for "any".
|
||||
Example: ["sova-transit"]
|
||||
incompatible_with:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9-]*_v[0-9]+_[0-9]+$"
|
||||
description: >
|
||||
Module IDs that cannot run concurrently with this one.
|
||||
The storyteller will not activate both in the same playthrough.
|
||||
max_concurrent:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
description: >
|
||||
Maximum simultaneous active instances. Almost always 1.
|
||||
Set to 2+ only for modules designed to stack (rare).
|
||||
|
||||
# ── ENTRY CONDITIONS ──────────────────────────────────────────────────────
|
||||
# All listed conditions must be true for the module to become activatable.
|
||||
# The storyteller checks these each tick after min_play_ticks.
|
||||
|
||||
entry_conditions:
|
||||
type: object
|
||||
required:
|
||||
- activation
|
||||
additionalProperties: false
|
||||
description: >
|
||||
World-state prerequisites. The storyteller activates the module when
|
||||
ALL conditions are satisfied AND the activation trigger fires.
|
||||
properties:
|
||||
world_state:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/$defs/world_state_condition"
|
||||
description: "World-state conditions checked each tick."
|
||||
player:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/$defs/player_condition"
|
||||
description: >
|
||||
Optional player-state conditions. Module can activate without
|
||||
player engagement — these gate on player-specific world state,
|
||||
not on player noticing the module.
|
||||
activation:
|
||||
type: object
|
||||
required:
|
||||
- trigger
|
||||
additionalProperties: false
|
||||
description: "How and when activation is evaluated."
|
||||
properties:
|
||||
trigger:
|
||||
type: string
|
||||
enum:
|
||||
- proximity # Player comes within range of a key NPC/location
|
||||
- storyteller_push # Storyteller activates on its own schedule
|
||||
- player_action # Player performs a specific action
|
||||
description: "What pushes the module from 'eligible' to 'active'."
|
||||
min_play_ticks:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: >
|
||||
Minimum ticks of game time before this module can activate.
|
||||
Enforces D-027 success criterion #1: 30 minutes of daily-life
|
||||
breathing room. At 1 tick/second, 30 minutes ≈ 1800 ticks.
|
||||
proximity_location:
|
||||
type: string
|
||||
description: >
|
||||
Required when trigger = proximity. Location slug the player
|
||||
must enter or approach. Example: "maintenance-corridors"
|
||||
proximity_radius_tiles:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: >
|
||||
Required when trigger = proximity. Tile radius around the
|
||||
location's anchor point.
|
||||
player_action_required:
|
||||
type: string
|
||||
description: >
|
||||
Required when trigger = player_action. The action that fires
|
||||
activation. Example: "examine:cargo-manifest"
|
||||
|
||||
# ── NPC REQUIREMENTS ──────────────────────────────────────────────────────
|
||||
# NPC slots this module requires. Each slot is filled at module load time.
|
||||
# Named bindings resolve to specific authored NPCs; generated bindings
|
||||
# are filled from the district's generated NPC pool.
|
||||
|
||||
npc_requirements:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/$defs/npc_slot"
|
||||
description: >
|
||||
Module-internal NPC role slots. Roles are referenced by slug throughout
|
||||
the rest of this document. Hand-authored NPCs use named bindings.
|
||||
Generated NPCs use constraint-based bindings.
|
||||
|
||||
# ── EVENTS ────────────────────────────────────────────────────────────────
|
||||
# Ordered sequences and unordered event pools the storyteller can fire.
|
||||
# Sequences are narrative beats in a defined order.
|
||||
# Pools are events the storyteller can fire in any order when conditions are met.
|
||||
|
||||
events:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: "Event sequences and pools the storyteller manages."
|
||||
properties:
|
||||
sequences:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/$defs/event_sequence"
|
||||
description: >
|
||||
Ordered event sequences. Steps fire in order; the next step
|
||||
becomes eligible only after the previous one fires.
|
||||
pools:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/$defs/event_pool"
|
||||
description: >
|
||||
Unordered event pools. The storyteller may fire any eligible
|
||||
event in the pool when its trigger conditions are met.
|
||||
|
||||
# ── OUTCOMES ──────────────────────────────────────────────────────────────
|
||||
# Resolution states the module can reach. The storyteller checks outcome
|
||||
# conditions each tick. First matching outcome wins.
|
||||
# Every module MUST include an expiry outcome.
|
||||
|
||||
outcomes:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/$defs/outcome"
|
||||
description: >
|
||||
Terminal and transitional resolution states. The storyteller checks
|
||||
these each tick and applies the first matching outcome.
|
||||
|
||||
# ── AUTHORING NOTES ───────────────────────────────────────────────────────
|
||||
|
||||
notes:
|
||||
type: string
|
||||
description: "Authoring-only field. Design rationale, cross-references. Ignored at load time."
|
||||
|
||||
dual_lens:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: "Authoring-only. How smuggler vs detective experience this module."
|
||||
properties:
|
||||
smuggler: { type: string }
|
||||
detective: { type: string }
|
||||
|
||||
# ── SHARED DEFINITIONS ────────────────────────────────────────────────────────
|
||||
|
||||
$defs:
|
||||
|
||||
# World-state condition types
|
||||
|
||||
world_state_condition:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
description: "A single world-state prerequisite for module activation."
|
||||
oneOf:
|
||||
- # NPC with the given module role is present in the district
|
||||
properties:
|
||||
type: { type: string, const: "npc_present" }
|
||||
role: { type: string, description: "Module-internal NPC role slug." }
|
||||
required: [type, role]
|
||||
additionalProperties: false
|
||||
|
||||
- # A specific location is accessible to the player
|
||||
properties:
|
||||
type: { type: string, const: "location_accessible" }
|
||||
location: { type: string, description: "Location slug." }
|
||||
required: [type, location]
|
||||
additionalProperties: false
|
||||
|
||||
- # Player has NOT yet discovered a specific fact
|
||||
properties:
|
||||
type: { type: string, const: "fact_not_known" }
|
||||
fact_id: { type: string, description: "Fact ID from global/knowledge/." }
|
||||
required: [type, fact_id]
|
||||
additionalProperties: false
|
||||
|
||||
- # No other Tier 1 module of the given ID is currently active
|
||||
properties:
|
||||
type: { type: string, const: "no_active_module" }
|
||||
module_id: { type: string }
|
||||
required: [type, module_id]
|
||||
additionalProperties: false
|
||||
|
||||
- # A named fact IS known (module requires precondition awareness)
|
||||
properties:
|
||||
type: { type: string, const: "fact_known" }
|
||||
fact_id: { type: string }
|
||||
known_by: { type: string, enum: [smuggler, detective, any] }
|
||||
required: [type, fact_id]
|
||||
additionalProperties: false
|
||||
|
||||
# Player-state condition types
|
||||
|
||||
player_condition:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
description: "A player-state prerequisite."
|
||||
oneOf:
|
||||
- # Player has reached minimum relationship threshold with an NPC
|
||||
properties:
|
||||
type: { type: string, const: "relationship_threshold" }
|
||||
npc_role: { type: string, description: "Module-internal NPC role." }
|
||||
min_state:
|
||||
type: string
|
||||
enum: [stranger, known, friendly]
|
||||
description: "Minimum RelationshipState required."
|
||||
required: [type, npc_role, min_state]
|
||||
additionalProperties: false
|
||||
|
||||
- # Minimum game ticks elapsed
|
||||
properties:
|
||||
type: { type: string, const: "min_ticks" }
|
||||
ticks: { type: integer, minimum: 0 }
|
||||
required: [type, ticks]
|
||||
additionalProperties: false
|
||||
|
||||
# NPC slot definition
|
||||
|
||||
npc_slot:
|
||||
type: object
|
||||
required:
|
||||
- role
|
||||
- binding
|
||||
additionalProperties: false
|
||||
description: >
|
||||
One NPC slot in the module. Named binding = specific authored NPC.
|
||||
Generated binding = constraint-matched NPC from district pool.
|
||||
properties:
|
||||
role:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9-]*$"
|
||||
description: >
|
||||
Module-internal role slug. Referenced in events, outcomes, and
|
||||
triggers. Example: "ring-leader", "ring-member-exiting", "witness"
|
||||
display_hint:
|
||||
type: string
|
||||
description: "Authoring note. What this role is narratively."
|
||||
binding:
|
||||
type: string
|
||||
enum: [named, generated]
|
||||
description: >
|
||||
named = resolves to a specific authored NPC (use named_npc).
|
||||
generated = any district NPC matching the axis constraints.
|
||||
named_npc:
|
||||
type: string
|
||||
pattern: "^npc:[a-z][a-z0-9-]*$"
|
||||
description: >
|
||||
Required when binding = named. Short-form NPC canonical ID.
|
||||
Example: "npc:kael-davan"
|
||||
axes:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/$defs/axis_constraint"
|
||||
description: >
|
||||
Required when binding = generated. The NPC must satisfy all
|
||||
listed axis constraints to fill this slot.
|
||||
must_have_pattern:
|
||||
type: string
|
||||
enum: [FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY]
|
||||
description: "Optional: NPC must have this pattern (D-024)."
|
||||
must_have_motivation:
|
||||
type: string
|
||||
enum: [HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC]
|
||||
description: "Optional: NPC must have this motivation (D-024)."
|
||||
is_optional:
|
||||
type: boolean
|
||||
default: false
|
||||
description: >
|
||||
If true, the module can activate without this slot filled.
|
||||
Optional slots produce degraded but valid module runs.
|
||||
|
||||
# NPC axis constraint (used in generated bindings)
|
||||
|
||||
axis_constraint:
|
||||
type: object
|
||||
required:
|
||||
- axis
|
||||
- constraint
|
||||
additionalProperties: false
|
||||
properties:
|
||||
axis:
|
||||
type: string
|
||||
enum: [want, secret, relationships, tolerance, routine, information, contentment, personality, tells, skills]
|
||||
description: "Which NPC axis to constrain (D-024)."
|
||||
constraint:
|
||||
type: string
|
||||
description: >
|
||||
Constraint expression. Freeform string interpreted by the storyteller.
|
||||
Convention: "has_{value}", "min_{N}", "not_{value}".
|
||||
Examples: "has_major_secret", "min_contentment_-3", "not_combat_trained"
|
||||
|
||||
# Event sequence
|
||||
|
||||
event_sequence:
|
||||
type: object
|
||||
required:
|
||||
- sequence_id
|
||||
- steps
|
||||
additionalProperties: false
|
||||
description: "An ordered sequence of narrative events."
|
||||
properties:
|
||||
sequence_id:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9_-]*$"
|
||||
label:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
steps:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/$defs/event_step"
|
||||
|
||||
# Unordered event pool
|
||||
|
||||
event_pool:
|
||||
type: object
|
||||
required:
|
||||
- pool_id
|
||||
- events
|
||||
additionalProperties: false
|
||||
properties:
|
||||
pool_id:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9_-]*$"
|
||||
label:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
events:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/$defs/event_step"
|
||||
|
||||
# Individual event step
|
||||
|
||||
event_step:
|
||||
type: object
|
||||
required:
|
||||
- event_id
|
||||
- triggers
|
||||
additionalProperties: false
|
||||
description: "A single storyteller-managed event with triggers and effects."
|
||||
properties:
|
||||
event_id:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9_-]*$"
|
||||
description: "Unique within this module. Used in outcome conditions."
|
||||
label:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
description: "What happens narratively when this event fires."
|
||||
triggers:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/$defs/event_trigger"
|
||||
description: "ANY trigger being true fires this event."
|
||||
effects:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/$defs/event_effect"
|
||||
description: "What changes in the world when this event fires."
|
||||
once:
|
||||
type: boolean
|
||||
default: true
|
||||
description: "If true, fires only once. If false, may repeat when conditions reset."
|
||||
sets_flag:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9_-]*$"
|
||||
description: "Module-internal flag set when this event fires. Queryable in outcomes."
|
||||
|
||||
# Event trigger conditions
|
||||
|
||||
event_trigger:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
description: "A condition that causes an event to fire."
|
||||
oneOf:
|
||||
- # Ticks elapsed since module activation
|
||||
properties:
|
||||
type: { type: string, const: "ticks_since_activation" }
|
||||
ticks: { type: integer, minimum: 1 }
|
||||
required: [type, ticks]
|
||||
additionalProperties: false
|
||||
|
||||
- # Ticks elapsed since a previous event fired
|
||||
properties:
|
||||
type: { type: string, const: "ticks_since_event" }
|
||||
after_event: { type: string }
|
||||
ticks: { type: integer, minimum: 1 }
|
||||
required: [type, after_event, ticks]
|
||||
additionalProperties: false
|
||||
|
||||
- # Player enters a location or comes within range of NPC
|
||||
properties:
|
||||
type: { type: string, const: "player_proximity" }
|
||||
target_type: { type: string, enum: [location, npc_role] }
|
||||
target: { type: string }
|
||||
radius_tiles: { type: integer, minimum: 1 }
|
||||
required: [type, target_type, target]
|
||||
additionalProperties: false
|
||||
|
||||
- # Player performs an interaction
|
||||
properties:
|
||||
type: { type: string, const: "player_action" }
|
||||
action:
|
||||
type: string
|
||||
enum: [talk, examine, confront, follow, observe]
|
||||
target_role: { type: string, description: "Module NPC role or location slug." }
|
||||
required: [type, action, target_role]
|
||||
additionalProperties: false
|
||||
|
||||
- # Player has discovered a specific fact
|
||||
properties:
|
||||
type: { type: string, const: "fact_known_by_player" }
|
||||
fact_id: { type: string }
|
||||
required: [type, fact_id]
|
||||
additionalProperties: false
|
||||
|
||||
- # A module flag has been set
|
||||
properties:
|
||||
type: { type: string, const: "flag_set" }
|
||||
flag: { type: string }
|
||||
required: [type, flag]
|
||||
additionalProperties: false
|
||||
|
||||
- # NPC enters a specific mood state
|
||||
properties:
|
||||
type: { type: string, const: "npc_mood" }
|
||||
npc_role: { type: string }
|
||||
mood:
|
||||
type: string
|
||||
enum: [anxious, frustrated, content, suspicious, warm, hostile, relieved, focused]
|
||||
required: [type, npc_role, mood]
|
||||
additionalProperties: false
|
||||
|
||||
# Event effects
|
||||
|
||||
event_effect:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
description: "A world change triggered by an event."
|
||||
oneOf:
|
||||
- # NPC deviates from their normal routine
|
||||
properties:
|
||||
type: { type: string, const: "npc_routine_deviation" }
|
||||
npc_role: { type: string }
|
||||
description: { type: string, description: "What the deviation looks like." }
|
||||
duration_ticks: { type: integer }
|
||||
required: [type, npc_role, description]
|
||||
additionalProperties: false
|
||||
|
||||
- # A fact becomes discoverable (moves to Rumoured confidence)
|
||||
properties:
|
||||
type: { type: string, const: "fact_becomes_discoverable" }
|
||||
fact_id: { type: string }
|
||||
discoverable_by:
|
||||
type: string
|
||||
enum: [smuggler, detective, any]
|
||||
discovery_method:
|
||||
type: string
|
||||
description: "How the player can discover this. Authoring note."
|
||||
required: [type, fact_id, discoverable_by]
|
||||
additionalProperties: false
|
||||
|
||||
- # NPC tell behavior becomes more pronounced
|
||||
properties:
|
||||
type: { type: string, const: "tell_intensify" }
|
||||
npc_role: { type: string }
|
||||
description: { type: string }
|
||||
required: [type, npc_role]
|
||||
additionalProperties: false
|
||||
|
||||
- # A module-internal flag is set
|
||||
properties:
|
||||
type: { type: string, const: "flag_set" }
|
||||
flag: { type: string, pattern: "^[a-z][a-z0-9_-]*$" }
|
||||
required: [type, flag]
|
||||
additionalProperties: false
|
||||
|
||||
- # Something changes about a location
|
||||
properties:
|
||||
type: { type: string, const: "location_state" }
|
||||
location: { type: string }
|
||||
description: { type: string }
|
||||
required: [type, location, description]
|
||||
additionalProperties: false
|
||||
|
||||
- # NPC's access to information changes
|
||||
properties:
|
||||
type: { type: string, const: "npc_knowledge_update" }
|
||||
npc_role: { type: string }
|
||||
fact_id: { type: string }
|
||||
description: { type: string }
|
||||
required: [type, npc_role, fact_id]
|
||||
additionalProperties: false
|
||||
|
||||
# Module outcome definition
|
||||
|
||||
outcome:
|
||||
type: object
|
||||
required:
|
||||
- outcome_id
|
||||
- label
|
||||
- is_terminal
|
||||
additionalProperties: false
|
||||
description: >
|
||||
A resolution state the module can reach. Conditions are checked each tick.
|
||||
The first matching outcome is applied. is_terminal = true ends the module.
|
||||
properties:
|
||||
outcome_id:
|
||||
type: string
|
||||
pattern: "^[a-z][a-z0-9_-]*$"
|
||||
label:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
description: "What this outcome means narratively."
|
||||
is_terminal:
|
||||
type: boolean
|
||||
description: "If true, this outcome ends the module permanently."
|
||||
is_expiry:
|
||||
type: boolean
|
||||
default: false
|
||||
description: >
|
||||
If true, this is the quiet-exit outcome when the player never engages.
|
||||
Every module must include exactly one expiry outcome.
|
||||
conditions:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: "ALL conditions must be true to reach this outcome."
|
||||
properties:
|
||||
facts_known:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: "Player must know all these facts."
|
||||
facts_not_known:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: "Player must NOT know any of these facts."
|
||||
flags_set:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: "All these module flags must be set."
|
||||
flags_not_set:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: "None of these module flags may be set."
|
||||
events_fired:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: "All these events must have fired."
|
||||
ticks_since_activation:
|
||||
type: integer
|
||||
description: "Module has been active for at least this many ticks."
|
||||
effects:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/$defs/outcome_effect"
|
||||
description: "Effects applied when this outcome is reached."
|
||||
|
||||
# Outcome-level effects (broader scope than event effects)
|
||||
|
||||
outcome_effect:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
oneOf:
|
||||
- # NPC disposition toward player changes
|
||||
properties:
|
||||
type: { type: string, const: "npc_disposition" }
|
||||
npc_role: { type: string }
|
||||
shift:
|
||||
type: string
|
||||
enum: [hostile, suspicious, neutral, friendly]
|
||||
description: { type: string }
|
||||
required: [type, npc_role, shift]
|
||||
additionalProperties: false
|
||||
|
||||
- # Faction reaction
|
||||
properties:
|
||||
type: { type: string, const: "faction_reaction" }
|
||||
faction: { type: string }
|
||||
reaction:
|
||||
type: string
|
||||
enum: [hostile, suspicious, neutral, friendly, grateful]
|
||||
description: { type: string }
|
||||
required: [type, faction, reaction]
|
||||
additionalProperties: false
|
||||
|
||||
- # Location becomes restricted or opens up
|
||||
properties:
|
||||
type: { type: string, const: "location_access_change" }
|
||||
location: { type: string }
|
||||
change:
|
||||
type: string
|
||||
enum: [restricted, locked, open]
|
||||
description: { type: string }
|
||||
required: [type, location, change]
|
||||
additionalProperties: false
|
||||
|
||||
- # A fact is now permanently known/unknown
|
||||
properties:
|
||||
type: { type: string, const: "fact_state" }
|
||||
fact_id: { type: string }
|
||||
state:
|
||||
type: string
|
||||
enum: [known, hidden, destroyed]
|
||||
description: { type: string }
|
||||
required: [type, fact_id, state]
|
||||
additionalProperties: false
|
||||
|
||||
- # NPC leaves the district or changes role
|
||||
properties:
|
||||
type: { type: string, const: "npc_exit" }
|
||||
npc_role: { type: string }
|
||||
description: { type: string }
|
||||
required: [type, npc_role]
|
||||
additionalProperties: false
|
||||
@@ -175,6 +175,11 @@ CREATE TABLE IF NOT EXISTS bodies (
|
||||
cultural_corridor TEXT, -- override system corridor if different
|
||||
industrial_corridor TEXT, -- MVG, Gate_Corp, DSMC, Prometheus, Agricultural_Syndic
|
||||
|
||||
-- Physical dimensions (D-204, #905)
|
||||
-- Mean radius in km. NULL until authoritative data is available; fallback
|
||||
-- derivation from planet_class is applied at query time by the generator.
|
||||
body_radius_km REAL,
|
||||
|
||||
-- Rendering
|
||||
-- terrain_reference: repo-root-relative path to the body's heightmap PNG.
|
||||
-- Convention (enforced by populate_terrain_reference.py and assumed by
|
||||
@@ -455,6 +460,72 @@ CREATE INDEX IF NOT EXISTS idx_atlas_pois_kind ON atlas_pois(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_rivers_body ON atlas_rivers(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_oceans_body ON atlas_oceans(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_mountain_ranges_body ON atlas_mountain_ranges(body_id);
|
||||
-- Heightmap BLOB storage — float32 LE, row-major (D-202, #901)
|
||||
-- Only inhabited bodies receive rows at build time; uninhabited bodies are
|
||||
-- generated on-demand by the runtime-background tier.
|
||||
CREATE TABLE IF NOT EXISTS atlas_body_heightmaps (
|
||||
body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
width INTEGER NOT NULL DEFAULT 512,
|
||||
height INTEGER NOT NULL DEFAULT 256,
|
||||
data BLOB NOT NULL, -- float32 LE, row-major, width×height values
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- City name reservations — replaces authored city positions in markers.json (D-207, #902)
|
||||
-- Position is generated by the city placement algorithm; name is authored or LLM-generated.
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'city', -- 'capital' | 'city'
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
settlement_class TEXT, -- D-196 SettlementClass variant; NULL until placement
|
||||
corp_id TEXT REFERENCES corporations(corp_id), -- nullable, corp HQ if applicable
|
||||
reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Geographic feature name reservations — rivers, mountains, passes (D-207 adjacent, #903)
|
||||
CREATE TABLE IF NOT EXISTS atlas_feature_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
feature_type TEXT NOT NULL, -- 'river' | 'mountain' | 'pass' | 'ocean' | 'region'
|
||||
priority INTEGER NOT NULL DEFAULT 0, -- higher = applied first during naming
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Province boundaries — watershed drainage basin polylines (D-205, #904)
|
||||
-- Pre-computed at build time from D8 drainage analysis.
|
||||
CREATE TABLE IF NOT EXISTS atlas_province_boundaries (
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
basin_id INTEGER NOT NULL,
|
||||
path TEXT NOT NULL, -- JSON array [[row, col], ...] pixel-space polyline
|
||||
area_pct REAL NOT NULL, -- fraction of body surface area in this basin
|
||||
PRIMARY KEY (body_id, basin_id)
|
||||
);
|
||||
|
||||
-- City positions — attractor-matched placement output (D-211, #34)
|
||||
-- Written at build time by the attractor-matching pipeline. Each row maps one
|
||||
-- atlas_city_names entry to its terrain position and the attractor that placed it.
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_positions (
|
||||
city_names_id INTEGER PRIMARY KEY REFERENCES atlas_city_names(id) ON DELETE CASCADE,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
row INTEGER NOT NULL, -- pixel row in heightmap grid [0, GRID_H)
|
||||
col INTEGER NOT NULL, -- pixel col in heightmap grid [0, GRID_W)
|
||||
attractor_type TEXT NOT NULL, -- AttractorType variant name
|
||||
score REAL NOT NULL -- match quality [0.0, 1.0]
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id);
|
||||
-- END ATLAS INDEX (D-191 §8, #832)
|
||||
|
||||
-- Indexes
|
||||
@@ -480,6 +551,27 @@ CREATE INDEX IF NOT EXISTS idx_corps_type ON corporations(corp_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_star_systems_currency ON star_systems(currency_zone);
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_links_from ON gate_links(from_system_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gate_links_to ON gate_links(to_system_id);
|
||||
|
||||
-- Generator metadata stamp (#855, #856, #888)
|
||||
-- One row per generator, updated on each successful non-dry-run.
|
||||
-- schema_version: monotonic semver string (e.g. "1.0.0") — bump on backwards-incompatible changes.
|
||||
-- Orderable, enabling savegame migration lineage (Phase 5+).
|
||||
-- Defined as SCHEMA_VERSION constant in tooling/schema_version.py.
|
||||
-- schema_sha: SHA-1 hex of systems-schema.sql content at generation time (tamper detection).
|
||||
-- generator_sha: SHA-1 hex of the generator source file(s) content
|
||||
-- generated_at: ISO-8601 UTC timestamp of the run
|
||||
--
|
||||
-- Used by:
|
||||
-- tooling/check-systems-db-stamp — verifies freshness before push (#857)
|
||||
-- .config/hooks/pre-push — rejects pushes with stale DB (#857)
|
||||
-- /pr-push skill — triggers make regen-db if stale (#858)
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas'
|
||||
schema_version TEXT NOT NULL, -- monotonic semver string (e.g. "1.0.0") — see #888
|
||||
schema_sha TEXT, -- SHA-1 hex of systems-schema.sql content (tamper detection)
|
||||
generator_sha TEXT NOT NULL, -- SHA-1 hex of generator source file(s) content
|
||||
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_commodities_tier ON commodities(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_production_chains_output ON production_chains(output_commodity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chain_inputs_commodity ON chain_inputs(input_commodity_id);
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,622 @@
|
||||
//! Attractor-matching five-phase pipeline for settlement placement (D-211).
|
||||
//!
|
||||
//! Given a body's `Vec<GeographicAttractor>` and a list of cities, assigns
|
||||
//! each city to the terrain feature that best fits its economic role and
|
||||
//! population tier.
|
||||
//!
|
||||
//! **Phases (D-211):**
|
||||
//! 1. Score matrix build: `CompatibilityMatrix[economic_role][attractor_type] × strength × (1/cost)`
|
||||
//! 2. Tier A greedy: `NameLocked` or pop ≥ 1,000,000 — assigned first, highest-score greedy.
|
||||
//! 3. Hungarian (Tier B+C): pop 50,000–999,999 cities — optimal global assignment.
|
||||
//! 4. Synthetic overflow: any remaining city gets a synthetic `PlainCenter` attractor.
|
||||
//! 5. Name fulfillment check: warn if any atlas city was not placed.
|
||||
//!
|
||||
//! **Mismatch flagging (D-211):**
|
||||
//! - score < 0.35 → WARNING
|
||||
//! - score < 0.15 → ERROR (flagged for manual review; generation continues)
|
||||
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::simulation::generator::{
|
||||
AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One city record from atlas_city_names, projected for matching.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CityRecord {
|
||||
pub city_id: u64,
|
||||
pub name: String,
|
||||
pub settlement_class: SettlementClass,
|
||||
pub population: i64,
|
||||
/// One of: manufacturing, financial, agricultural, extraction,
|
||||
/// service_mixed, institutional, transit_hub, research, military, residential.
|
||||
pub economic_role: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of matching one city to one attractor (real or synthetic).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CityPlacement {
|
||||
pub city_id: u64,
|
||||
pub position: (u16, u16),
|
||||
pub attractor_type: AttractorType,
|
||||
pub score: f32,
|
||||
pub synthetic: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Score matrix helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Row index in CompatibilityMatrix for an economic_role string.
|
||||
/// Order from D-195: manufacturing(0), financial(1), agricultural(2), extraction(3),
|
||||
/// service_mixed(4), institutional(5), transit_hub(6), research(7), military(8), residential(9).
|
||||
fn role_row(economic_role: &str) -> usize {
|
||||
match economic_role {
|
||||
"manufacturing" => 0,
|
||||
"financial" => 1,
|
||||
"agricultural" => 2,
|
||||
"extraction" => 3,
|
||||
"service_mixed" => 4,
|
||||
"institutional" => 5,
|
||||
"transit_hub" => 6,
|
||||
"research" => 7,
|
||||
"military" => 8,
|
||||
_ => 9,
|
||||
}
|
||||
}
|
||||
|
||||
/// Column index in CompatibilityMatrix for an AttractorType.
|
||||
/// Order from D-195: RiverMouth(0), CoastalAccess(1), RiverCrossing(2), ValleyFloor(3),
|
||||
/// PassEntrance(4), LakeShore(5), PlainCenter(6).
|
||||
fn attractor_col(at: &AttractorType) -> usize {
|
||||
match at {
|
||||
AttractorType::RiverMouth => 0,
|
||||
AttractorType::CoastalAccess => 1,
|
||||
AttractorType::RiverCrossing => 2,
|
||||
AttractorType::ValleyFloor => 3,
|
||||
AttractorType::PassEntrance => 4,
|
||||
AttractorType::LakeShore => 5,
|
||||
AttractorType::PlainCenter => 6,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the raw match score between a city and an attractor.
|
||||
/// Score = matrix_weight × attractor.strength × (1.0 / terrain_modification_cost).
|
||||
fn cell_score(
|
||||
city: &CityRecord,
|
||||
attractor: &GeographicAttractor,
|
||||
matrix: &CompatibilityMatrix,
|
||||
terrain_cost: f32,
|
||||
) -> f32 {
|
||||
let row = role_row(&city.economic_role);
|
||||
let col = attractor_col(&attractor.attractor_type);
|
||||
let weight = matrix.weights[row][col];
|
||||
let cost_factor = if terrain_cost > 0.0 {
|
||||
1.0 / terrain_cost
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
weight * attractor.strength * cost_factor
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3: Hungarian algorithm (minimization)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// O(n³) Hungarian algorithm for assignment problem.
|
||||
///
|
||||
/// Input: `cost[i][j]` — cost of assigning task j to worker i.
|
||||
/// Lower cost = better fit. Converts the maximization problem to minimization
|
||||
/// by using `max_score - score` as cost.
|
||||
///
|
||||
/// Returns `assignment[i] = j` for each row i.
|
||||
fn hungarian(cost: &[Vec<f32>]) -> Vec<usize> {
|
||||
let n = cost.len();
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let m = cost[0].len();
|
||||
if m == 0 {
|
||||
return vec![usize::MAX; n];
|
||||
}
|
||||
|
||||
// Pad to square n×n if m < n (more cities than attractors handled by overflow).
|
||||
let sz = n.max(m);
|
||||
let mut c: Vec<Vec<f32>> = vec![vec![0.0; sz]; sz];
|
||||
for i in 0..n {
|
||||
for j in 0..m {
|
||||
c[i][j] = cost[i][j];
|
||||
}
|
||||
// Pad extra columns with high cost so overflow cities pick them last.
|
||||
for item in c[i].iter_mut().take(sz).skip(m) {
|
||||
*item = f32::MAX / 2.0;
|
||||
}
|
||||
}
|
||||
// Pad extra rows with 0 cost (dummy workers).
|
||||
// Already initialized to 0.
|
||||
|
||||
// Standard O(n³) Hungarian.
|
||||
let inf = f32::MAX / 2.0;
|
||||
let mut u = vec![0.0f32; sz + 1];
|
||||
let mut v = vec![0.0f32; sz + 1];
|
||||
let mut p = vec![0usize; sz + 1]; // p[j] = row assigned to column j (1-indexed)
|
||||
let mut way = vec![0usize; sz + 1];
|
||||
|
||||
for i in 1..=sz {
|
||||
p[0] = i;
|
||||
let mut j0 = 0usize;
|
||||
let mut minv = vec![inf; sz + 1];
|
||||
let mut used = vec![false; sz + 1];
|
||||
loop {
|
||||
used[j0] = true;
|
||||
let i0 = p[j0];
|
||||
let mut delta = inf;
|
||||
let mut j1 = 0usize;
|
||||
for j in 1..=sz {
|
||||
if used[j] {
|
||||
continue;
|
||||
}
|
||||
let cur = c[i0 - 1][j - 1] - u[i0] - v[j];
|
||||
if cur < minv[j] {
|
||||
minv[j] = cur;
|
||||
way[j] = j0;
|
||||
}
|
||||
if minv[j] < delta {
|
||||
delta = minv[j];
|
||||
j1 = j;
|
||||
}
|
||||
}
|
||||
for j in 0..=sz {
|
||||
if used[j] {
|
||||
u[p[j]] += delta;
|
||||
v[j] -= delta;
|
||||
} else {
|
||||
minv[j] -= delta;
|
||||
}
|
||||
}
|
||||
j0 = j1;
|
||||
if p[j0] == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let j1 = way[j0];
|
||||
p[j0] = p[j1];
|
||||
j0 = j1;
|
||||
if j0 == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract assignment: for each row i (1-indexed), find column j where p[j] == i.
|
||||
let mut result = vec![usize::MAX; n];
|
||||
for j in 1..=sz {
|
||||
if p[j] > 0 && p[j] <= n {
|
||||
let col = j - 1;
|
||||
if col < m {
|
||||
result[p[j] - 1] = col;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic PlainCenter placement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Minimum pixel separation between synthetic attractor positions.
|
||||
const MIN_SPACING: u16 = 15;
|
||||
|
||||
fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> GeographicAttractor {
|
||||
// Place at grid center as default, then walk until spacing is satisfied.
|
||||
let mut row = (grid_h / 2) as u16;
|
||||
let mut col = (grid_w / 4) as u16;
|
||||
|
||||
// Simple search: try positions in a grid until spacing is met.
|
||||
'outer: for dr in 0..(grid_h as u16 / MIN_SPACING) {
|
||||
for dc in 0..(grid_w as u16 / MIN_SPACING) {
|
||||
let r = (dr * MIN_SPACING).min(grid_h as u16 - 1);
|
||||
let c = (dc * MIN_SPACING).min(grid_w as u16 - 1);
|
||||
let ok = placed.iter().all(|p| {
|
||||
let dr2 = (p.position.0 as i32 - r as i32).unsigned_abs() as u16;
|
||||
let dc2 = (p.position.1 as i32 - c as i32).unsigned_abs() as u16;
|
||||
dr2.max(dc2) >= MIN_SPACING
|
||||
});
|
||||
if ok {
|
||||
row = r;
|
||||
col = c;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GeographicAttractor {
|
||||
position: (row, col),
|
||||
attractor_type: AttractorType::PlainCenter,
|
||||
strength: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the five-phase attractor-matching pipeline (D-211).
|
||||
///
|
||||
/// `terrain_costs` maps attractor index → terrain_modification_cost (1.0 = baseline).
|
||||
/// If `None`, all costs default to 1.0.
|
||||
pub fn match_cities(
|
||||
cities: &[CityRecord],
|
||||
attractors: &[GeographicAttractor],
|
||||
matrix: &CompatibilityMatrix,
|
||||
terrain_costs: Option<&[f32]>,
|
||||
grid_w: u32,
|
||||
grid_h: u32,
|
||||
) -> Vec<CityPlacement> {
|
||||
let default_cost = vec![1.0f32; attractors.len()];
|
||||
let costs = terrain_costs.unwrap_or(&default_cost);
|
||||
|
||||
let mut placements: Vec<CityPlacement> = Vec::with_capacity(cities.len());
|
||||
let mut used_attractors: Vec<bool> = vec![false; attractors.len()];
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 1: Score matrix
|
||||
// -------------------------------------------------------------------------
|
||||
let scores: Vec<Vec<f32>> = cities
|
||||
.iter()
|
||||
.map(|city| {
|
||||
attractors
|
||||
.iter()
|
||||
.zip(costs.iter())
|
||||
.map(|(att, &cost)| cell_score(city, att, matrix, cost))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 2: Tier A greedy — NameLocked or pop ≥ 1_000_000
|
||||
// -------------------------------------------------------------------------
|
||||
let tier_a_indices: Vec<usize> = cities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
c.settlement_class == SettlementClass::NameLocked || c.population >= 1_000_000
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
for &ci in &tier_a_indices {
|
||||
if attractors.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Highest-scoring unused attractor.
|
||||
let best = scores[ci]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(ai, _)| !used_attractors[*ai])
|
||||
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
if let Some((ai, &score)) = best {
|
||||
used_attractors[ai] = true;
|
||||
flag_mismatch(&cities[ci].name, score);
|
||||
placements.push(CityPlacement {
|
||||
city_id: cities[ci].city_id,
|
||||
position: attractors[ai].position,
|
||||
attractor_type: attractors[ai].attractor_type.clone(),
|
||||
score,
|
||||
synthetic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 3: Hungarian — Tier B+C (50,000–999,999)
|
||||
// -------------------------------------------------------------------------
|
||||
let tier_bc_indices: Vec<usize> = cities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, c)| {
|
||||
!tier_a_indices.contains(i) && c.population >= 50_000 && c.population < 1_000_000
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
let free_attractors: Vec<usize> = (0..attractors.len())
|
||||
.filter(|&ai| !used_attractors[ai])
|
||||
.collect();
|
||||
|
||||
if !tier_bc_indices.is_empty() && !free_attractors.is_empty() {
|
||||
// Build cost sub-matrix (maximization → minimization via complement).
|
||||
let scores_ref = &scores;
|
||||
let max_score: f32 = tier_bc_indices
|
||||
.iter()
|
||||
.flat_map(|&ci| free_attractors.iter().map(move |&ai| scores_ref[ci][ai]))
|
||||
.fold(0.0f32, f32::max);
|
||||
|
||||
let cost: Vec<Vec<f32>> = tier_bc_indices
|
||||
.iter()
|
||||
.map(|&ci| {
|
||||
free_attractors
|
||||
.iter()
|
||||
.map(|&ai| max_score - scores_ref[ci][ai])
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let assignment = hungarian(&cost);
|
||||
|
||||
for (local_i, &ci) in tier_bc_indices.iter().enumerate() {
|
||||
let local_j = assignment[local_i];
|
||||
if local_j == usize::MAX || local_j >= free_attractors.len() {
|
||||
continue; // overflow — handled in phase 4
|
||||
}
|
||||
let ai = free_attractors[local_j];
|
||||
let score = scores[ci][ai];
|
||||
used_attractors[ai] = true;
|
||||
flag_mismatch(&cities[ci].name, score);
|
||||
placements.push(CityPlacement {
|
||||
city_id: cities[ci].city_id,
|
||||
position: attractors[ai].position,
|
||||
attractor_type: attractors[ai].attractor_type.clone(),
|
||||
score,
|
||||
synthetic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 4: Synthetic overflow — all remaining cities
|
||||
// -------------------------------------------------------------------------
|
||||
let placed_ids: std::collections::BTreeSet<u64> =
|
||||
placements.iter().map(|p| p.city_id).collect();
|
||||
|
||||
for city in cities {
|
||||
if placed_ids.contains(&city.city_id) {
|
||||
continue;
|
||||
}
|
||||
let synthetic = synthetic_attractor(&placements, grid_w, grid_h);
|
||||
let score = cell_score(city, &synthetic, matrix, 1.0);
|
||||
flag_mismatch(&city.name, score);
|
||||
placements.push(CityPlacement {
|
||||
city_id: city.city_id,
|
||||
position: synthetic.position,
|
||||
attractor_type: AttractorType::PlainCenter,
|
||||
score,
|
||||
synthetic: true,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 5: Name fulfillment check
|
||||
// -------------------------------------------------------------------------
|
||||
let placed_ids: std::collections::BTreeSet<u64> =
|
||||
placements.iter().map(|p| p.city_id).collect();
|
||||
for city in cities {
|
||||
if !placed_ids.contains(&city.city_id) {
|
||||
warn!(
|
||||
city = %city.name,
|
||||
city_id = city.city_id,
|
||||
"atlas city was not placed — missing from pipeline output"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
placements
|
||||
}
|
||||
|
||||
fn flag_mismatch(city_name: &str, score: f32) {
|
||||
if score < 0.15 {
|
||||
error!(
|
||||
city = %city_name,
|
||||
score,
|
||||
"attractor mismatch score < 0.15 — flagged for manual review"
|
||||
);
|
||||
} else if score < 0.35 {
|
||||
warn!(
|
||||
city = %city_name,
|
||||
score,
|
||||
"attractor mismatch score < 0.35 — below expected quality"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FoundingOrientation derivation from matched attractor (D-211, D-213)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use crate::simulation::generator::FoundingOrientation;
|
||||
use crate::simulation::generator::TerritorialStatus;
|
||||
|
||||
/// Derive `FoundingOrientation` from the attractor type that anchored the city (D-211, D-213).
|
||||
///
|
||||
/// `river_bearing` and `coastal_facing` are compass degrees 0–359.
|
||||
/// Pass 0 as default when the terrain doesn't dictate a specific bearing.
|
||||
pub fn founding_orientation(
|
||||
attractor_type: &AttractorType,
|
||||
territorial_status: &TerritorialStatus,
|
||||
river_bearing: u16,
|
||||
coastal_facing: u16,
|
||||
) -> FoundingOrientation {
|
||||
match attractor_type {
|
||||
AttractorType::RiverMouth | AttractorType::CoastalAccess => FoundingOrientation::Coastal {
|
||||
facing_degrees: coastal_facing,
|
||||
},
|
||||
AttractorType::RiverCrossing => FoundingOrientation::RiverAligned {
|
||||
bearing_degrees: river_bearing,
|
||||
},
|
||||
AttractorType::ValleyFloor => FoundingOrientation::TerrainFollowing,
|
||||
AttractorType::PlainCenter => {
|
||||
if matches!(territorial_status, TerritorialStatus::CommissionControlled) {
|
||||
FoundingOrientation::Cardinal
|
||||
} else {
|
||||
FoundingOrientation::Free { bearing_degrees: 0 }
|
||||
}
|
||||
}
|
||||
AttractorType::PassEntrance | AttractorType::LakeShore => {
|
||||
FoundingOrientation::TerrainFollowing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor};
|
||||
|
||||
fn uniform_matrix() -> CompatibilityMatrix {
|
||||
CompatibilityMatrix {
|
||||
weights: [[1.0; 7]; 10],
|
||||
}
|
||||
}
|
||||
|
||||
fn make_attractor(row: u16, col: u16, at: AttractorType, strength: f32) -> GeographicAttractor {
|
||||
GeographicAttractor {
|
||||
position: (row, col),
|
||||
attractor_type: at,
|
||||
strength,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_city(id: u64, class: SettlementClass, pop: i64) -> CityRecord {
|
||||
CityRecord {
|
||||
city_id: id,
|
||||
name: format!("City{id}"),
|
||||
settlement_class: class,
|
||||
population: pop,
|
||||
economic_role: "manufacturing".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_city_single_attractor() {
|
||||
let cities = vec![make_city(1, SettlementClass::NameLocked, 500_000)];
|
||||
let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 0.8)];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 1);
|
||||
assert_eq!(placements[0].city_id, 1);
|
||||
assert_eq!(placements[0].position, (10, 20));
|
||||
assert!(!placements[0].synthetic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_a_gets_priority() {
|
||||
// NameLocked city should get the best attractor (high strength).
|
||||
let cities = vec![
|
||||
make_city(1, SettlementClass::NameLocked, 100_000),
|
||||
make_city(2, SettlementClass::PopulationBudget, 200_000),
|
||||
];
|
||||
let attractors = vec![
|
||||
make_attractor(5, 5, AttractorType::RiverMouth, 0.9), // best
|
||||
make_attractor(10, 10, AttractorType::ValleyFloor, 0.4), // second
|
||||
];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
let p1 = placements.iter().find(|p| p.city_id == 1).unwrap();
|
||||
assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_produces_synthetic() {
|
||||
// 2 cities, 1 attractor → second city gets synthetic.
|
||||
let cities = vec![
|
||||
make_city(1, SettlementClass::NameLocked, 2_000_000),
|
||||
make_city(2, SettlementClass::PopulationBudget, 60_000),
|
||||
];
|
||||
let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 1.0)];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 2);
|
||||
let p2 = placements.iter().find(|p| p.city_id == 2).unwrap();
|
||||
assert!(p2.synthetic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_cities_placed() {
|
||||
let cities: Vec<CityRecord> = (1..=5)
|
||||
.map(|i| make_city(i, SettlementClass::PopulationBudget, 100_000))
|
||||
.collect();
|
||||
let attractors = vec![
|
||||
make_attractor(10, 10, AttractorType::RiverMouth, 0.9),
|
||||
make_attractor(20, 20, AttractorType::CoastalAccess, 0.7),
|
||||
];
|
||||
let matrix = uniform_matrix();
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 5, "all cities must be placed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hungarian_assigns_optimally() {
|
||||
// 2 cities, 2 attractors. City A scores best on attractor 0, city B best on attractor 1.
|
||||
let mut matrix = uniform_matrix();
|
||||
// agricultural (row 2) scores high on ValleyFloor (col 3) = 3.0
|
||||
matrix.weights[2][3] = 3.0;
|
||||
// transit_hub (row 6) scores high on RiverCrossing (col 2) = 3.0
|
||||
matrix.weights[6][2] = 3.0;
|
||||
let cities = vec![
|
||||
CityRecord {
|
||||
city_id: 1,
|
||||
name: "Farm".to_string(),
|
||||
settlement_class: SettlementClass::PopulationBudget,
|
||||
population: 60_000,
|
||||
economic_role: "agricultural".to_string(),
|
||||
},
|
||||
CityRecord {
|
||||
city_id: 2,
|
||||
name: "Hub".to_string(),
|
||||
settlement_class: SettlementClass::PopulationBudget,
|
||||
population: 80_000,
|
||||
economic_role: "transit_hub".to_string(),
|
||||
},
|
||||
];
|
||||
let attractors = vec![
|
||||
make_attractor(5, 5, AttractorType::ValleyFloor, 1.0),
|
||||
make_attractor(10, 10, AttractorType::RiverCrossing, 1.0),
|
||||
];
|
||||
let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256);
|
||||
assert_eq!(placements.len(), 2);
|
||||
let farm = placements.iter().find(|p| p.city_id == 1).unwrap();
|
||||
let hub = placements.iter().find(|p| p.city_id == 2).unwrap();
|
||||
// Farm should be on ValleyFloor (5,5), Hub on RiverCrossing (10,10).
|
||||
assert_eq!(farm.position, (5, 5));
|
||||
assert_eq!(hub.position, (10, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn founding_orientation_from_attractor() {
|
||||
use crate::simulation::generator::TerritorialStatus;
|
||||
let status = TerritorialStatus::FrontierUnclaimed;
|
||||
let o = founding_orientation(&AttractorType::RiverMouth, &status, 90, 270);
|
||||
assert!(matches!(
|
||||
o,
|
||||
FoundingOrientation::Coastal {
|
||||
facing_degrees: 270
|
||||
}
|
||||
));
|
||||
|
||||
let o2 = founding_orientation(
|
||||
&AttractorType::PlainCenter,
|
||||
&TerritorialStatus::CommissionControlled,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(o2, FoundingOrientation::Cardinal));
|
||||
|
||||
let o3 = founding_orientation(&AttractorType::ValleyFloor, &status, 0, 0);
|
||||
assert!(matches!(o3, FoundingOrientation::TerrainFollowing));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! BlockIrregularity derivation from founding_age and PoliticalArchetype (D-216).
|
||||
//!
|
||||
//! `block_irregularity` (0.0–1.0) controls how much a block deviates from
|
||||
//! the district's canonical grid. Minimum 0.05 — no block is perfectly regular.
|
||||
//!
|
||||
//! **Formula (D-216):**
|
||||
//! ```text
|
||||
//! base_irregularity = (founding_age_years / 1000.0).min(1.0)
|
||||
//! archetype_step = Commission|Military → -0.3, Corporate|Academic → -0.1,
|
||||
//! Industrial → 0.0, Pioneer → +0.3
|
||||
//! block_irregularity = (base + step).clamp(0.05, 1.0)
|
||||
//! ```
|
||||
//!
|
||||
//! **Determinism (D-010, D-216):** Integer-scaled intermediates; archetype_step
|
||||
//! stored as basis points (i32, 1 bp = 0.001). Final result is f32 from integer
|
||||
//! arithmetic to match the D-216 formula.
|
||||
|
||||
use crate::simulation::generator::PoliticalArchetype;
|
||||
|
||||
/// Compute the `block_irregularity` value for one block.
|
||||
///
|
||||
/// - `founding_age_years`: years since the settlement was founded (integer).
|
||||
/// - `archetype`: the settlement's political archetype.
|
||||
///
|
||||
/// Returns a value in [0.05, 1.0].
|
||||
pub fn block_irregularity(founding_age_years: u32, archetype: &PoliticalArchetype) -> f32 {
|
||||
// base_irregularity in integer basis-points (0–1000, where 1000 = 1.0).
|
||||
let base_bp: i32 = (founding_age_years as i32).min(1000);
|
||||
|
||||
// archetype_step in basis-points.
|
||||
let step_bp: i32 = archetype_step_bp(archetype);
|
||||
|
||||
// block_irregularity_bp clamped to [50, 1000] (0.05–1.0).
|
||||
let result_bp = (base_bp + step_bp).clamp(50, 1000);
|
||||
|
||||
result_bp as f32 / 1000.0
|
||||
}
|
||||
|
||||
fn archetype_step_bp(archetype: &PoliticalArchetype) -> i32 {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission | PoliticalArchetype::Military => -300,
|
||||
PoliticalArchetype::Corporate | PoliticalArchetype::Academic => -100,
|
||||
PoliticalArchetype::Industrial => 0,
|
||||
PoliticalArchetype::Pioneer => 300,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the maximum block offset in sim tiles from `block_irregularity`.
|
||||
///
|
||||
/// Used by `DistrictLayoutMode::Organic`: `max_offset = (irregularity × 16.0) as i16`.
|
||||
pub fn max_offset_sim_tiles(irregularity: f32) -> i16 {
|
||||
(irregularity * 16.0) as i16
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minimum_is_0_05() {
|
||||
// New Commission city (age 0) → base 0, step -300 → clamp to 50bp = 0.05.
|
||||
let v = block_irregularity(0, &PoliticalArchetype::Commission);
|
||||
assert!((v - 0.05).abs() < 1e-6, "expected 0.05, got {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_is_1_0() {
|
||||
// Old Pioneer city (age 1000+) → base 1000, step +300 → clamp to 1000bp = 1.0.
|
||||
let v = block_irregularity(1500, &PoliticalArchetype::Pioneer);
|
||||
assert!((v - 1.0).abs() < 1e-6, "expected 1.0, got {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pioneer_more_irregular_than_commission() {
|
||||
let pioneer = block_irregularity(400, &PoliticalArchetype::Pioneer);
|
||||
let commission = block_irregularity(400, &PoliticalArchetype::Commission);
|
||||
assert!(
|
||||
pioneer > commission,
|
||||
"Pioneer ({pioneer}) should be more irregular than Commission ({commission})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn age_increases_irregularity() {
|
||||
let young = block_irregularity(50, &PoliticalArchetype::Industrial);
|
||||
let old = block_irregularity(800, &PoliticalArchetype::Industrial);
|
||||
assert!(
|
||||
old > young,
|
||||
"Older settlement ({old}) should be more irregular than young ({young})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_offset_scales_with_irregularity() {
|
||||
assert_eq!(max_offset_sim_tiles(0.05), 0); // 0.05 × 16 = 0.8 → 0
|
||||
assert_eq!(max_offset_sim_tiles(1.0), 16);
|
||||
assert_eq!(max_offset_sim_tiles(0.5), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_archetypes_produce_valid_range() {
|
||||
let archetypes = [
|
||||
PoliticalArchetype::Commission,
|
||||
PoliticalArchetype::Corporate,
|
||||
PoliticalArchetype::Pioneer,
|
||||
PoliticalArchetype::Military,
|
||||
PoliticalArchetype::Academic,
|
||||
PoliticalArchetype::Industrial,
|
||||
];
|
||||
for a in &archetypes {
|
||||
let v = block_irregularity(300, a);
|
||||
assert!(
|
||||
v >= 0.05 && v <= 1.0,
|
||||
"archetype {:?} gave {v} out of [0.05, 1.0]",
|
||||
a
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! BodyWorldState — per-body Layer 1–2 cache (D-203).
|
||||
//!
|
||||
//! `BodyWorldStateCache` is a Bevy `Resource` holding pre-computed generation
|
||||
//! data for up to 50 planetary bodies. Populated by the runtime-background
|
||||
//! tier (D-206) via Rayon tasks; read by the main tick thread without blocking.
|
||||
//!
|
||||
//! Eviction policy: LRU — the body with the oldest `last_accessed` tick is
|
||||
//! evicted on overflow, unless it is pinned (current player location or an
|
||||
//! adjacent-system neighbor).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use bevy_ecs::prelude::Resource;
|
||||
|
||||
use crate::simulation::generator::GeographicAttractor;
|
||||
|
||||
/// Simulation tick counter — monotonically increasing u64.
|
||||
pub type SimTick = u64;
|
||||
|
||||
/// Maximum number of bodies the cache holds before evicting the LRU entry.
|
||||
pub const CACHE_CAPACITY: usize = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stub types — filled in by D-208 (#918) and D-205 (#907 Rust side)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// River network extracted by the D8 drainage algorithm (D-208).
|
||||
/// Stub — replaced when #918 is implemented.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RiverNetwork {
|
||||
/// Pixel positions (row, col) of all river cells (flow_accumulation > 200).
|
||||
pub river_cells: Vec<(u16, u16)>,
|
||||
/// Positions where two or more rivers merge.
|
||||
pub confluences: Vec<(u16, u16)>,
|
||||
/// Positions where rivers reach sea level or the heightmap edge.
|
||||
pub mouths: Vec<(u16, u16)>,
|
||||
}
|
||||
|
||||
/// One drainage basin / province derived from watershed analysis (D-205).
|
||||
/// Stub — boundary polyline data comes from atlas_province_boundaries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DrainageBasin {
|
||||
pub basin_id: u32,
|
||||
/// Boundary polyline as pixel-space (row, col) points.
|
||||
pub boundary: Vec<(u16, u16)>,
|
||||
/// Fraction of the body's surface area in this basin.
|
||||
pub area_pct: f32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BodyWorldState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pre-computed Layer 1–2 generation data for one planetary body.
|
||||
///
|
||||
/// Produced by the runtime-background tier and stored in `BodyWorldStateCache`.
|
||||
/// The main tick thread reads this data without performing any DB or CPU work.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BodyWorldState {
|
||||
pub body_id: String,
|
||||
/// Downsampled working elevation grid (float32, row-major).
|
||||
/// Full-resolution data lives in atlas_body_heightmaps; this is reduced
|
||||
/// for the ~8KB working-resolution budget described in D-203.
|
||||
pub heightmap: Vec<f32>,
|
||||
pub heightmap_width: u32,
|
||||
pub heightmap_height: u32,
|
||||
/// D8 drainage analysis output (D-208). Empty until drainage task completes.
|
||||
pub river_network: RiverNetwork,
|
||||
/// Drainage basins from watershed analysis (D-205).
|
||||
pub drainage_basins: Vec<DrainageBasin>,
|
||||
/// Geographic attractors (D-195, D-209). Empty until attractor task completes.
|
||||
pub attractors: Vec<GeographicAttractor>,
|
||||
/// Last sim tick this entry was read. Used for LRU eviction.
|
||||
pub last_accessed: SimTick,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BodyWorldStateCache — Bevy Resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy `Resource` holding the LRU cache of per-body world state (D-203).
|
||||
///
|
||||
/// Initialized empty at server startup. Entries are inserted by the
|
||||
/// background generation queue (D-206) and read by main-thread systems.
|
||||
///
|
||||
/// All mutations go through the provided methods to maintain the
|
||||
/// invariant that `entries.len() <= capacity`.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct BodyWorldStateCache {
|
||||
entries: BTreeMap<String, BodyWorldState>,
|
||||
/// Body IDs that must not be evicted regardless of `last_accessed`.
|
||||
pinned: BTreeSet<String>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl BodyWorldStateCache {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
entries: BTreeMap::new(),
|
||||
pinned: BTreeSet::new(),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert or replace a `BodyWorldState` entry.
|
||||
///
|
||||
/// If the cache is at capacity, evicts the LRU unpinned entry before
|
||||
/// inserting. If all entries are pinned and the cache is full, the new
|
||||
/// entry is inserted anyway (capacity is a soft limit against unbounded
|
||||
/// growth, not a hard reject).
|
||||
pub fn insert(&mut self, state: BodyWorldState) {
|
||||
if self.entries.len() >= self.capacity && !self.entries.contains_key(&state.body_id) {
|
||||
self.evict_lru();
|
||||
}
|
||||
self.entries.insert(state.body_id.clone(), state);
|
||||
}
|
||||
|
||||
/// Get a reference to the state for `body_id`, bumping `last_accessed`.
|
||||
pub fn get(&mut self, body_id: &str, current_tick: SimTick) -> Option<&BodyWorldState> {
|
||||
if let Some(entry) = self.entries.get_mut(body_id) {
|
||||
entry.last_accessed = current_tick;
|
||||
}
|
||||
self.entries.get(body_id)
|
||||
}
|
||||
|
||||
/// Get a reference without bumping `last_accessed` (read-only path).
|
||||
pub fn peek(&self, body_id: &str) -> Option<&BodyWorldState> {
|
||||
self.entries.get(body_id)
|
||||
}
|
||||
|
||||
/// Returns `true` if the cache has an entry for `body_id`.
|
||||
pub fn contains(&self, body_id: &str) -> bool {
|
||||
self.entries.contains_key(body_id)
|
||||
}
|
||||
|
||||
/// Pin `body_id` — exempt from LRU eviction.
|
||||
pub fn pin(&mut self, body_id: &str) {
|
||||
self.pinned.insert(body_id.to_string());
|
||||
}
|
||||
|
||||
/// Unpin `body_id` — allow eviction again.
|
||||
pub fn unpin(&mut self, body_id: &str) {
|
||||
self.pinned.remove(body_id);
|
||||
}
|
||||
|
||||
/// Number of entries currently in the cache.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
fn evict_lru(&mut self) {
|
||||
// Find the unpinned entry with the smallest last_accessed tick.
|
||||
let victim = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(id, _)| !self.pinned.contains(*id))
|
||||
.min_by_key(|(_, s)| s.last_accessed)
|
||||
.map(|(id, _)| id.clone());
|
||||
|
||||
if let Some(id) = victim {
|
||||
self.entries.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[allow(unused_imports)]
|
||||
use crate::simulation::generator::GeographicAttractor;
|
||||
|
||||
fn make_state(body_id: &str, tick: SimTick) -> BodyWorldState {
|
||||
BodyWorldState {
|
||||
body_id: body_id.to_string(),
|
||||
heightmap: vec![0.5; 16],
|
||||
heightmap_width: 4,
|
||||
heightmap_height: 4,
|
||||
river_network: RiverNetwork::default(),
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
last_accessed: tick,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_get() {
|
||||
let mut cache = BodyWorldStateCache::new(50);
|
||||
cache.insert(make_state("Alpha", 1));
|
||||
assert!(cache.contains("Alpha"));
|
||||
assert!(!cache.contains("Beta"));
|
||||
let entry = cache.get("Alpha", 5).unwrap();
|
||||
assert_eq!(entry.body_id, "Alpha");
|
||||
assert_eq!(entry.last_accessed, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicts_lru_on_overflow() {
|
||||
let mut cache = BodyWorldStateCache::new(3);
|
||||
cache.insert(make_state("A", 10));
|
||||
cache.insert(make_state("B", 20));
|
||||
cache.insert(make_state("C", 30));
|
||||
// Cache is full; inserting D should evict A (oldest tick = 10).
|
||||
cache.insert(make_state("D", 40));
|
||||
assert_eq!(cache.len(), 3);
|
||||
assert!(!cache.contains("A"), "A should have been evicted");
|
||||
assert!(cache.contains("B"));
|
||||
assert!(cache.contains("C"));
|
||||
assert!(cache.contains("D"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_body_not_evicted() {
|
||||
let mut cache = BodyWorldStateCache::new(3);
|
||||
cache.insert(make_state("A", 10));
|
||||
cache.insert(make_state("B", 20));
|
||||
cache.insert(make_state("C", 30));
|
||||
// Pin A so it cannot be evicted.
|
||||
cache.pin("A");
|
||||
// Inserting D must evict B (oldest unpinned).
|
||||
cache.insert(make_state("D", 40));
|
||||
assert!(cache.contains("A"), "pinned A must not be evicted");
|
||||
assert!(!cache.contains("B"), "B should have been evicted instead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_last_accessed_on_get() {
|
||||
let mut cache = BodyWorldStateCache::new(3);
|
||||
cache.insert(make_state("A", 1));
|
||||
cache.insert(make_state("B", 2));
|
||||
cache.insert(make_state("C", 3));
|
||||
// Cache is full. Get A at tick 100 — bumps its last_accessed above C and B.
|
||||
cache.get("A", 100);
|
||||
// Insert D to trigger eviction; B (tick 2) is now LRU, not A (tick 100).
|
||||
cache.insert(make_state("D", 4));
|
||||
assert!(
|
||||
cache.contains("A"),
|
||||
"A was recently accessed — must survive"
|
||||
);
|
||||
assert!(
|
||||
!cache.contains("B"),
|
||||
"B had oldest access time — should be evicted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_capacity_is_zero() {
|
||||
// Default resource starts empty.
|
||||
let cache = BodyWorldStateCache::default();
|
||||
assert!(cache.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! Three-component district mix algorithm for city district type distribution (D-194).
|
||||
//!
|
||||
//! Given a city's population, economic role, and political archetype, produces
|
||||
//! a district type distribution (count of each DistrictType) used by the
|
||||
//! Phase 1 district skeleton generator.
|
||||
//!
|
||||
//! **Components (D-194):**
|
||||
//! 1. Population tier guarantees — minimum district counts by city size.
|
||||
//! 2. 10×9 economic multiplier table — economic role × DistrictType weights.
|
||||
//! 3. Political archetype modifiers — shift weights for specific district types.
|
||||
//!
|
||||
//! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the
|
||||
//! district count computation. Seed-driven noise uses seeded RNG.
|
||||
|
||||
use crate::atlas::rng::AtlasRng;
|
||||
use crate::simulation::generator::{DistrictType, PoliticalArchetype};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Population tier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Population tier: `floor(log10(pop / 1_000_000))`, capped at [0, 5].
|
||||
pub fn population_tier(population: i64) -> u8 {
|
||||
if population <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let ratio = population as f64 / 1_000_000.0;
|
||||
if ratio <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let tier = ratio.log10().floor() as i32;
|
||||
tier.clamp(0, 5) as u8
|
||||
}
|
||||
|
||||
/// Minimum district counts guaranteed by population tier (D-194).
|
||||
///
|
||||
/// Returns `(transit_min, commercial_min, residential_min)`.
|
||||
pub fn tier_guarantees(tier: u8) -> (u32, u32, u32) {
|
||||
match tier {
|
||||
0 => (0, 0, 1),
|
||||
1 => (0, 1, 1),
|
||||
2 => (1, 1, 2),
|
||||
3 => (1, 2, 3),
|
||||
4 => (2, 3, 4),
|
||||
5 => (3, 4, 6),
|
||||
_ => (3, 4, 6),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Economic multiplier table (10×9, integer weights × 10 for precision)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// District type column order (0–8).
|
||||
/// Matches DistrictType enum variants: LogisticsHub, Residential, Commercial,
|
||||
/// Industrial, Administrative, Entertainment, MixedUse, Transit, Specialized.
|
||||
const DIST_COLS: [DistrictType; 9] = [
|
||||
DistrictType::LogisticsHub,
|
||||
DistrictType::Residential,
|
||||
DistrictType::Commercial,
|
||||
DistrictType::Industrial,
|
||||
DistrictType::Administrative,
|
||||
DistrictType::Entertainment,
|
||||
DistrictType::MixedUse,
|
||||
DistrictType::Transit,
|
||||
DistrictType::Specialized,
|
||||
];
|
||||
|
||||
/// Map a DistrictType to its column index.
|
||||
fn dist_col(dt: &DistrictType) -> usize {
|
||||
match dt {
|
||||
DistrictType::LogisticsHub => 0,
|
||||
DistrictType::Residential => 1,
|
||||
DistrictType::Commercial => 2,
|
||||
DistrictType::Industrial => 3,
|
||||
DistrictType::Administrative => 4,
|
||||
DistrictType::Entertainment => 5,
|
||||
DistrictType::MixedUse => 6,
|
||||
DistrictType::Transit => 7,
|
||||
DistrictType::Specialized => 8,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an economic role to its row index (0–9).
|
||||
fn role_row(economic_role: &str) -> usize {
|
||||
match economic_role {
|
||||
"manufacturing" => 0,
|
||||
"financial" => 1,
|
||||
"agricultural" => 2,
|
||||
"extraction" => 3,
|
||||
"service_mixed" => 4,
|
||||
"institutional" => 5,
|
||||
"transit_hub" => 6,
|
||||
"research" => 7,
|
||||
"military" => 8,
|
||||
_ => 9,
|
||||
}
|
||||
}
|
||||
|
||||
/// 10×9 economic multiplier table. Values are integer weights × 10.
|
||||
/// Rows: manufacturing(0), financial(1), agricultural(2), extraction(3),
|
||||
/// service_mixed(4), institutional(5), transit_hub(6), research(7),
|
||||
/// military(8), residential(9).
|
||||
/// Columns: LogisticsHub(0), Residential(1), Commercial(2), Industrial(3),
|
||||
/// Administrative(4), Entertainment(5), MixedUse(6), Transit(7),
|
||||
/// Specialized(8).
|
||||
#[rustfmt::skip]
|
||||
const ECON_TABLE: [[u32; 9]; 10] = [
|
||||
// LH Re Co In Ad En Mu Tr Sp
|
||||
[25, 10, 15, 30, 10, 5, 10, 20, 10], // manufacturing
|
||||
[10, 15, 30, 10, 20, 15, 20, 15, 10], // financial
|
||||
[20, 20, 10, 15, 10, 5, 20, 10, 5], // agricultural
|
||||
[30, 10, 10, 30, 10, 5, 5, 15, 10], // extraction
|
||||
[15, 20, 25, 10, 10, 20, 25, 20, 10], // service_mixed
|
||||
[10, 15, 10, 10, 30, 10, 10, 10, 20], // institutional
|
||||
[25, 10, 15, 10, 10, 10, 10, 30, 10], // transit_hub
|
||||
[10, 15, 10, 15, 20, 10, 10, 10, 30], // research
|
||||
[10, 20, 5, 15, 20, 5, 5, 10, 15], // military
|
||||
[10, 30, 15, 5, 10, 15, 25, 10, 5], // residential
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Political archetype modifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Additive integer modifiers to column weights based on `PoliticalArchetype`.
|
||||
/// Returns `[mod; 9]` for columns in `DIST_COLS` order.
|
||||
fn archetype_modifiers(archetype: &PoliticalArchetype) -> [i32; 9] {
|
||||
match archetype {
|
||||
PoliticalArchetype::Commission => {
|
||||
// Boosts Administrative + Institutional-style Specialized.
|
||||
[0, 0, 0, 0, 10, 0, 0, 0, 5]
|
||||
}
|
||||
PoliticalArchetype::Corporate => {
|
||||
// Boosts Commercial + Specialized (restricted campus zones).
|
||||
[0, -5, 15, 0, 0, 5, 0, 0, 10]
|
||||
}
|
||||
PoliticalArchetype::Pioneer => {
|
||||
// Boosts MixedUse + organic Residential.
|
||||
[0, 10, 5, 0, -5, 5, 15, 0, 0]
|
||||
}
|
||||
PoliticalArchetype::Military => {
|
||||
// Boosts Administrative + reduces Entertainment.
|
||||
[0, 5, -5, 5, 15, -10, 0, 0, 10]
|
||||
}
|
||||
PoliticalArchetype::Academic => {
|
||||
// Boosts Specialized (research labs) + Administrative.
|
||||
[0, 5, 0, 0, 10, 5, 5, 0, 20]
|
||||
}
|
||||
PoliticalArchetype::Industrial => {
|
||||
// Boosts Industrial + LogisticsHub.
|
||||
[10, -5, 5, 20, 0, -5, 0, 5, 5]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// District mix computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The district type distribution for a generated city.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DistrictMix {
|
||||
/// Ordered list of district types for the city, with repetition (district_count items total).
|
||||
pub districts: Vec<DistrictType>,
|
||||
/// Total district count.
|
||||
pub total: u32,
|
||||
}
|
||||
|
||||
/// Compute the district mix for one city (D-194).
|
||||
///
|
||||
/// `total_districts` is the number of districts to allocate. A good default is
|
||||
/// `max(4, population_tier * 2)`.
|
||||
///
|
||||
/// `seed` is the city-level RNG seed (D-010 determinism).
|
||||
pub fn compute_district_mix(
|
||||
population: i64,
|
||||
economic_role: &str,
|
||||
archetype: &PoliticalArchetype,
|
||||
total_districts: u32,
|
||||
seed: u64,
|
||||
) -> DistrictMix {
|
||||
let tier = population_tier(population);
|
||||
let (transit_min, commercial_min, residential_min) = tier_guarantees(tier);
|
||||
let row = role_row(economic_role);
|
||||
let arch_mods = archetype_modifiers(archetype);
|
||||
|
||||
// Build effective weights (integer, clamped to ≥ 1).
|
||||
let mut weights: [u32; 9] = [0; 9];
|
||||
for col in 0..9 {
|
||||
let base = ECON_TABLE[row][col] as i32;
|
||||
let modified = base + arch_mods[col];
|
||||
weights[col] = modified.max(1) as u32;
|
||||
}
|
||||
|
||||
// Allocate districts proportionally from weights using a seeded LCG.
|
||||
// We avoid f32 by using integer weighted random selection.
|
||||
let weight_sum: u32 = weights.iter().sum();
|
||||
let mut counts: [u32; 9] = [0; 9];
|
||||
let mut lcg = AtlasRng::new(seed.wrapping_add(1));
|
||||
|
||||
for _ in 0..total_districts {
|
||||
let mut pick = lcg.next_u32() % weight_sum;
|
||||
for col in 0..9 {
|
||||
if pick < weights[col] {
|
||||
counts[col] += 1;
|
||||
break;
|
||||
}
|
||||
pick -= weights[col];
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tier guarantees (add if under minimum).
|
||||
let transit_col = dist_col(&DistrictType::Transit);
|
||||
let commercial_col = dist_col(&DistrictType::Commercial);
|
||||
let residential_col = dist_col(&DistrictType::Residential);
|
||||
|
||||
if counts[transit_col] < transit_min {
|
||||
counts[transit_col] = transit_min;
|
||||
}
|
||||
if counts[commercial_col] < commercial_min {
|
||||
counts[commercial_col] = commercial_min;
|
||||
}
|
||||
if counts[residential_col] < residential_min {
|
||||
counts[residential_col] = residential_min;
|
||||
}
|
||||
|
||||
// Build the flat ordered list.
|
||||
let mut districts: Vec<DistrictType> = Vec::new();
|
||||
for (col, &count) in counts.iter().enumerate() {
|
||||
for _ in 0..count {
|
||||
districts.push(DIST_COLS[col].clone());
|
||||
}
|
||||
}
|
||||
|
||||
let total = districts.len() as u32;
|
||||
DistrictMix { districts, total }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn population_tier_values() {
|
||||
assert_eq!(population_tier(0), 0);
|
||||
assert_eq!(population_tier(50_000), 0); // 0.05M → log10 < 0 → tier 0
|
||||
assert_eq!(population_tier(1_000_000), 0); // 1M → log10(1) = 0 → tier 0
|
||||
assert_eq!(population_tier(10_000_000), 1); // 10M → log10(10) = 1 → tier 1
|
||||
assert_eq!(population_tier(100_000_000), 2); // 100M → tier 2
|
||||
assert_eq!(population_tier(1_000_000_000_000), 5); // capped at 5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_sums_at_least_to_requested() {
|
||||
let mix = compute_district_mix(
|
||||
5_000_000,
|
||||
"manufacturing",
|
||||
&PoliticalArchetype::Industrial,
|
||||
8,
|
||||
42,
|
||||
);
|
||||
// total may exceed requested due to guarantees
|
||||
assert!(mix.total >= 8, "district count should be >= requested");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_guarantees_applied() {
|
||||
// Tier 2 city: pop/1M = 100–999, log10(100) = 2.
|
||||
// 100M population → pop_tier = floor(log10(100)) = 2 → (1 Transit, 1 Commercial, 2 Residential).
|
||||
let mix = compute_district_mix(
|
||||
100_000_000,
|
||||
"service_mixed",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
6,
|
||||
7,
|
||||
);
|
||||
let transit = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Transit))
|
||||
.count();
|
||||
let commercial = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Commercial))
|
||||
.count();
|
||||
let residential = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Residential))
|
||||
.count();
|
||||
assert!(transit >= 1, "transit guarantee not met: {transit}");
|
||||
assert!(
|
||||
commercial >= 1,
|
||||
"commercial guarantee not met: {commercial}"
|
||||
);
|
||||
assert!(
|
||||
residential >= 2,
|
||||
"residential guarantee not met: {residential}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_same_seed() {
|
||||
let mix1 = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Commission,
|
||||
6,
|
||||
99,
|
||||
);
|
||||
let mix2 = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Commission,
|
||||
6,
|
||||
99,
|
||||
);
|
||||
assert_eq!(mix1, mix2, "same inputs must produce identical output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_archetypes_produce_different_mixes() {
|
||||
let mix_corp = compute_district_mix(
|
||||
5_000_000,
|
||||
"financial",
|
||||
&PoliticalArchetype::Corporate,
|
||||
8,
|
||||
42,
|
||||
);
|
||||
let mix_pioneer =
|
||||
compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Pioneer, 8, 42);
|
||||
// Should differ in at least one district type count.
|
||||
assert_ne!(
|
||||
mix_corp.districts, mix_pioneer.districts,
|
||||
"Corporate and Pioneer archetypes should produce different district mixes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn military_archetype_has_administrative() {
|
||||
let mix = compute_district_mix(2_000_000, "military", &PoliticalArchetype::Military, 8, 10);
|
||||
let admin = mix
|
||||
.districts
|
||||
.iter()
|
||||
.filter(|d| matches!(d, DistrictType::Administrative))
|
||||
.count();
|
||||
assert!(
|
||||
admin >= 1,
|
||||
"military archetype should have Administrative districts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_district_types_can_appear() {
|
||||
// With enough districts and a balanced role, every type should appear at least once.
|
||||
let mix = compute_district_mix(
|
||||
50_000_000,
|
||||
"service_mixed",
|
||||
&PoliticalArchetype::Pioneer,
|
||||
50,
|
||||
0,
|
||||
);
|
||||
for dt in &DIST_COLS {
|
||||
let present = mix
|
||||
.districts
|
||||
.iter()
|
||||
.any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt));
|
||||
assert!(
|
||||
present,
|
||||
"DistrictType {:?} never appeared in 50-district mix",
|
||||
dt
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
//! D8 drainage routing — flow direction, flow accumulation, river network
|
||||
//! extraction, and drainage basin delineation (D-208).
|
||||
//!
|
||||
//! **Determinism (D-010, D-208):** All flow-direction comparisons use integer
|
||||
//! arithmetic on scaled elevation values (`(elev * 1_000_000.0) as i64`) to
|
||||
//! avoid f32 comparison non-determinism. Tie-breaking uses a fixed D8 neighbor
|
||||
//! priority order. The result is bit-identical across runs on the same inputs.
|
||||
//!
|
||||
//! **Algorithm:**
|
||||
//! 1. Scale f32 elevation to i64 integers.
|
||||
//! 2. Priority-flood depression fill (iterative, convergence in ≤10 passes).
|
||||
//! 3. D8 flow direction: steepest descent, 8-neighbor, wraps horizontally.
|
||||
//! 4. Flow accumulation via topological sort of the D8 DAG.
|
||||
//! 5. River network extraction: cells with accumulation > RIVER_THRESHOLD.
|
||||
//! 6. Basin labeling: flood-fill seeded at pour points.
|
||||
//!
|
||||
//! The grid is row-major. Row 0 is the north pole; row H-1 is the south pole.
|
||||
//! Columns wrap horizontally (the globe is equirectangular).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork};
|
||||
|
||||
/// A cell is a river cell when its flow accumulation exceeds this threshold (D-208).
|
||||
pub const RIVER_THRESHOLD: i32 = 200;
|
||||
|
||||
/// Scale factor for converting f32 elevation to integer for deterministic comparison.
|
||||
const ELEV_SCALE: f64 = 1_000_000.0;
|
||||
|
||||
// D8 neighbor offsets (dr, dc) in fixed priority order for deterministic tie-breaking.
|
||||
// Priority: cardinal directions first (N, S, E, W), then diagonals (NE, NW, SE, SW).
|
||||
const D8: [(i32, i32); 8] = [
|
||||
(-1, 0), // N
|
||||
(1, 0), // S
|
||||
(0, 1), // E
|
||||
(0, -1), // W
|
||||
(-1, 1), // NE
|
||||
(-1, -1), // NW
|
||||
(1, 1), // SE
|
||||
(1, -1), // SW
|
||||
];
|
||||
|
||||
/// Result of the full D8 drainage analysis for one body.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DrainageResult {
|
||||
pub river_network: RiverNetwork,
|
||||
pub drainage_basins: Vec<DrainageBasin>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the full D8 drainage analysis on an elevation grid.
|
||||
///
|
||||
/// `elevation` is a row-major float32 grid of shape `height × width`, values
|
||||
/// in [0.0, 1.0]. `sea_level` is the fraction below which terrain is ocean.
|
||||
///
|
||||
/// Returns `DrainageResult` with the river network and drainage basins.
|
||||
pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> DrainageResult {
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
// 1. Scale to integers.
|
||||
let scaled: Vec<i64> = elevation
|
||||
.iter()
|
||||
.map(|&e| (e as f64 * ELEV_SCALE) as i64)
|
||||
.collect();
|
||||
|
||||
// 2. Depression fill.
|
||||
let filled = depression_fill(&scaled, w, h);
|
||||
|
||||
// 3. D8 flow direction. -1 = no outflow (edge or flat peak).
|
||||
let fdir = flow_direction(&filled, w, h);
|
||||
|
||||
// 4. Flow accumulation.
|
||||
let accum = flow_accumulation(&fdir, w, h);
|
||||
|
||||
// 5. River network.
|
||||
let river_network = extract_river_network(&accum, &fdir, w, h, sea_level, elevation);
|
||||
|
||||
// 6. Basin labeling.
|
||||
let labels = label_basins(&fdir, &accum, w, h);
|
||||
|
||||
// 7. Merge small basins + clamp count to [4, 12].
|
||||
let labels = merge_small_basins(labels, w, h, 4, 12);
|
||||
|
||||
// 8. Build DrainageBasin structs.
|
||||
let drainage_basins = build_basins(&labels, w, h);
|
||||
|
||||
DrainageResult {
|
||||
river_network,
|
||||
drainage_basins,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2: Depression fill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn depression_fill(scaled: &[i64], w: usize, h: usize) -> Vec<i64> {
|
||||
let mut filled = scaled.to_vec();
|
||||
for _ in 0..10 {
|
||||
let mut changed = false;
|
||||
for r in 1..h.saturating_sub(1) {
|
||||
for c in 0..w {
|
||||
let mut nbr_min = i64::MAX;
|
||||
for &(dr, dc) in &D8 {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 {
|
||||
let val = filled[nr as usize * w + nc];
|
||||
if val < nbr_min {
|
||||
nbr_min = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
if filled[r * w + c] < nbr_min {
|
||||
filled[r * w + c] = nbr_min + 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
filled
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3: D8 flow direction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns per-cell flow direction index into D8 (0–7), or -1 for no outflow.
|
||||
fn flow_direction(filled: &[i64], w: usize, h: usize) -> Vec<i8> {
|
||||
let mut fdir = vec![-1i8; w * h];
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let elev = filled[r * w + c];
|
||||
let mut best_drop = 0i64;
|
||||
let mut best_k: i8 = -1;
|
||||
for (k, &(dr, dc)) in D8.iter().enumerate() {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let drop = elev - filled[nr as usize * w + nc];
|
||||
if drop > best_drop {
|
||||
best_drop = drop;
|
||||
best_k = k as i8;
|
||||
}
|
||||
}
|
||||
fdir[r * w + c] = best_k;
|
||||
}
|
||||
}
|
||||
fdir
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 4: Flow accumulation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn flow_accumulation(fdir: &[i8], w: usize, h: usize) -> Vec<i32> {
|
||||
let n = w * h;
|
||||
let mut in_degree = vec![0i32; n];
|
||||
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let k = fdir[r * w + c];
|
||||
if k < 0 {
|
||||
continue;
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 {
|
||||
in_degree[nr as usize * w + nc] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut queue = VecDeque::new();
|
||||
for (i, °) in in_degree.iter().enumerate().take(n) {
|
||||
if deg == 0 {
|
||||
queue.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
let mut accum = vec![1i32; n];
|
||||
while let Some(idx) = queue.pop_front() {
|
||||
let r = idx / w;
|
||||
let c = idx % w;
|
||||
let k = fdir[idx];
|
||||
if k < 0 {
|
||||
continue;
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 {
|
||||
let ni = nr as usize * w + nc;
|
||||
accum[ni] += accum[idx];
|
||||
in_degree[ni] -= 1;
|
||||
if in_degree[ni] == 0 {
|
||||
queue.push_back(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accum
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 5: River network extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn extract_river_network(
|
||||
accum: &[i32],
|
||||
fdir: &[i8],
|
||||
w: usize,
|
||||
h: usize,
|
||||
sea_level: f32,
|
||||
elevation: &[f32],
|
||||
) -> RiverNetwork {
|
||||
let n = w * h;
|
||||
|
||||
// River cells: above threshold AND above sea level.
|
||||
let is_river: Vec<bool> = (0..n)
|
||||
.map(|i| accum[i] > RIVER_THRESHOLD && elevation[i] >= sea_level)
|
||||
.collect();
|
||||
|
||||
let river_cells: Vec<(u16, u16)> = (0..n)
|
||||
.filter(|&i| is_river[i])
|
||||
.map(|i| ((i / w) as u16, (i % w) as u16))
|
||||
.collect();
|
||||
|
||||
// Confluences: river cells with 2+ river neighbors flowing into them.
|
||||
let mut inflow_count = vec![0u8; n];
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let i = r * w + c;
|
||||
if !is_river[i] {
|
||||
continue;
|
||||
}
|
||||
let k = fdir[i];
|
||||
if k < 0 {
|
||||
continue;
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 {
|
||||
let ni = nr as usize * w + nc;
|
||||
if is_river[ni] {
|
||||
inflow_count[ni] = inflow_count[ni].saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let confluences: Vec<(u16, u16)> = (0..n)
|
||||
.filter(|&i| is_river[i] && inflow_count[i] >= 2)
|
||||
.map(|i| ((i / w) as u16, (i % w) as u16))
|
||||
.collect();
|
||||
|
||||
// Mouths: river cells that flow to a sea cell or to the polar edge.
|
||||
let mouths: Vec<(u16, u16)> = (0..n)
|
||||
.filter(|&i| {
|
||||
if !is_river[i] {
|
||||
return false;
|
||||
}
|
||||
let r = i / w;
|
||||
let c = i % w;
|
||||
let k = fdir[i];
|
||||
if k < 0 {
|
||||
return true; // no outflow — edge
|
||||
}
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
return true; // polar edge
|
||||
}
|
||||
// Flows into a sub-sea-level cell = mouth
|
||||
elevation[nr as usize * w + nc] < sea_level
|
||||
})
|
||||
.map(|i| ((i / w) as u16, (i % w) as u16))
|
||||
.collect();
|
||||
|
||||
RiverNetwork {
|
||||
river_cells,
|
||||
confluences,
|
||||
mouths,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 6: Basin labeling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn label_basins(fdir: &[i8], accum: &[i32], w: usize, h: usize) -> Vec<i32> {
|
||||
let n = w * h;
|
||||
let mut labels = vec![-1i32; n];
|
||||
|
||||
// Pour points: local accumulation maxima above river threshold.
|
||||
let mut pour_pts: Vec<usize> = Vec::new();
|
||||
for i in 0..n {
|
||||
if accum[i] <= RIVER_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
let r = i / w;
|
||||
let c = i % w;
|
||||
let mut is_max = true;
|
||||
for &(dr, dc) in &D8 {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 && accum[nr as usize * w + nc] > accum[i] {
|
||||
is_max = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if is_max {
|
||||
pour_pts.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if pour_pts.is_empty() {
|
||||
// Flat/ocean world — single basin.
|
||||
labels.iter_mut().for_each(|l| *l = 0);
|
||||
return labels;
|
||||
}
|
||||
|
||||
for (basin_id, &idx) in pour_pts.iter().enumerate() {
|
||||
labels[idx] = basin_id as i32;
|
||||
}
|
||||
|
||||
// Trace remaining cells: follow fdir until a labeled cell is reached.
|
||||
for start in 0..n {
|
||||
if labels[start] >= 0 {
|
||||
continue;
|
||||
}
|
||||
// Walk forward, accumulate path.
|
||||
let mut path: Vec<usize> = Vec::new();
|
||||
let mut cur = start;
|
||||
let label = loop {
|
||||
if labels[cur] >= 0 {
|
||||
break labels[cur];
|
||||
}
|
||||
path.push(cur);
|
||||
let k = fdir[cur];
|
||||
if k < 0 {
|
||||
break 0; // no outflow — assign to basin 0
|
||||
}
|
||||
let r = cur / w;
|
||||
let c = cur % w;
|
||||
let (dr, dc) = D8[k as usize];
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
break 0; // polar edge
|
||||
}
|
||||
let next = nr as usize * w + nc;
|
||||
// Cycle guard: if we're visiting a cell already in path, stop.
|
||||
if path.contains(&next) {
|
||||
break 0;
|
||||
}
|
||||
cur = next;
|
||||
};
|
||||
for idx in path {
|
||||
labels[idx] = label;
|
||||
}
|
||||
}
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 7: Merge small basins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn merge_small_basins(
|
||||
mut labels: Vec<i32>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
min_count: usize,
|
||||
max_count: usize,
|
||||
) -> Vec<i32> {
|
||||
let n = w * h;
|
||||
let min_frac = 0.02f64; // 2% minimum basin area
|
||||
|
||||
for _ in 0..200 {
|
||||
// Count basin sizes.
|
||||
let mut sizes: std::collections::BTreeMap<i32, usize> = std::collections::BTreeMap::new();
|
||||
for &l in &labels {
|
||||
*sizes.entry(l).or_insert(0) += 1;
|
||||
}
|
||||
let n_basins = sizes.len();
|
||||
|
||||
// Stop if within target range and all basins are large enough.
|
||||
if n_basins <= max_count && sizes.values().all(|&s| s as f64 / n as f64 >= min_frac) {
|
||||
break;
|
||||
}
|
||||
if n_basins <= min_count {
|
||||
break;
|
||||
}
|
||||
|
||||
// Find the smallest basin.
|
||||
let (&smallest_id, &smallest_size) = sizes.iter().min_by_key(|(_, &s)| s).unwrap();
|
||||
|
||||
if n_basins <= max_count && smallest_size as f64 / n as f64 >= min_frac {
|
||||
break;
|
||||
}
|
||||
|
||||
// Find its largest adjacent basin.
|
||||
let nbr_id = find_largest_neighbor(&labels, smallest_id, &sizes, w, h);
|
||||
let merge_into = nbr_id.unwrap_or(0);
|
||||
|
||||
// Merge.
|
||||
for l in labels.iter_mut() {
|
||||
if *l == smallest_id {
|
||||
*l = merge_into;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renumber contiguously from 0.
|
||||
let unique: Vec<i32> = {
|
||||
let mut set: std::collections::BTreeSet<i32> = std::collections::BTreeSet::new();
|
||||
for &l in &labels {
|
||||
set.insert(l);
|
||||
}
|
||||
set.into_iter().collect()
|
||||
};
|
||||
let remap: std::collections::BTreeMap<i32, i32> = unique
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(new, &old)| (old, new as i32))
|
||||
.collect();
|
||||
for l in labels.iter_mut() {
|
||||
*l = remap[l];
|
||||
}
|
||||
|
||||
labels
|
||||
}
|
||||
|
||||
fn find_largest_neighbor(
|
||||
labels: &[i32],
|
||||
target_id: i32,
|
||||
sizes: &std::collections::BTreeMap<i32, usize>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
) -> Option<i32> {
|
||||
let n = w * h;
|
||||
let mut neighbor_sizes: std::collections::BTreeMap<i32, usize> =
|
||||
std::collections::BTreeMap::new();
|
||||
|
||||
for i in 0..n {
|
||||
if labels[i] != target_id {
|
||||
continue;
|
||||
}
|
||||
let r = i / w;
|
||||
let c = i % w;
|
||||
for &(dr, dc) in &D8 {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr >= 0 && nr < h as i32 {
|
||||
let nbr_id = labels[nr as usize * w + nc];
|
||||
if nbr_id != target_id {
|
||||
let size = sizes.get(&nbr_id).copied().unwrap_or(0);
|
||||
let e = neighbor_sizes.entry(nbr_id).or_insert(0);
|
||||
if size > *e {
|
||||
*e = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
neighbor_sizes
|
||||
.into_iter()
|
||||
.max_by_key(|(_, s)| *s)
|
||||
.map(|(id, _)| id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 8: Build DrainageBasin structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn build_basins(labels: &[i32], w: usize, h: usize) -> Vec<DrainageBasin> {
|
||||
let n = w * h;
|
||||
let mut basin_map: std::collections::BTreeMap<i32, Vec<usize>> =
|
||||
std::collections::BTreeMap::new();
|
||||
|
||||
for (i, &l) in labels.iter().enumerate() {
|
||||
basin_map.entry(l).or_default().push(i);
|
||||
}
|
||||
|
||||
let mut basins: Vec<DrainageBasin> = Vec::with_capacity(basin_map.len());
|
||||
let mut ids: Vec<i32> = basin_map.keys().copied().collect();
|
||||
ids.sort();
|
||||
|
||||
for basin_id in ids {
|
||||
let cells = &basin_map[&basin_id];
|
||||
let area_pct = cells.len() as f32 / n as f32;
|
||||
|
||||
// Boundary cells: in this basin, adjacent to a different basin or edge.
|
||||
let mut boundary: Vec<(u16, u16)> = Vec::new();
|
||||
for &idx in cells {
|
||||
let r = idx / w;
|
||||
let c = idx % w;
|
||||
let mut on_boundary = false;
|
||||
for &(dr, dc) in &D8 {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = (c as i32 + dc).rem_euclid(w as i32) as usize;
|
||||
if nr < 0 || nr >= h as i32 {
|
||||
on_boundary = true;
|
||||
break;
|
||||
}
|
||||
if labels[nr as usize * w + nc] != basin_id {
|
||||
on_boundary = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if on_boundary {
|
||||
boundary.push((r as u16, c as u16));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort boundary by angle from centroid for a coherent polygon.
|
||||
if !boundary.is_empty() {
|
||||
let cr = boundary.iter().map(|&(r, _)| r as f32).sum::<f32>() / boundary.len() as f32;
|
||||
let cc = boundary.iter().map(|&(_, c)| c as f32).sum::<f32>() / boundary.len() as f32;
|
||||
boundary.sort_by(|&(r1, c1), &(r2, c2)| {
|
||||
let a1 = (r1 as f32 - cr).atan2(c1 as f32 - cc);
|
||||
let a2 = (r2 as f32 - cr).atan2(c2 as f32 - cc);
|
||||
a1.partial_cmp(&a2).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
// Subsample to ≤500 points.
|
||||
if boundary.len() > 500 {
|
||||
let step = boundary.len() / 500;
|
||||
boundary = boundary.into_iter().step_by(step).collect();
|
||||
}
|
||||
}
|
||||
|
||||
basins.push(DrainageBasin {
|
||||
basin_id: basin_id as u32,
|
||||
boundary,
|
||||
area_pct,
|
||||
});
|
||||
}
|
||||
|
||||
basins
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn flat_grid(w: u32, h: u32, val: f32) -> Vec<f32> {
|
||||
vec![val; (w * h) as usize]
|
||||
}
|
||||
|
||||
fn slope_grid(w: u32, h: u32) -> Vec<f32> {
|
||||
let n = (w * h) as usize;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let r = i / w as usize;
|
||||
let c = i % w as usize;
|
||||
// Slope: higher in top-left, drains toward bottom-right.
|
||||
1.0 - (r as f32 / h as f32 * 0.5 + c as f32 / w as f32 * 0.5)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_grid_produces_single_basin() {
|
||||
let elev = flat_grid(16, 8, 0.5);
|
||||
let result = analyze(&elev, 16, 8, 0.3);
|
||||
// Flat world → no pour points → single basin
|
||||
assert_eq!(result.drainage_basins.len(), 1);
|
||||
assert!((result.drainage_basins[0].area_pct - 1.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slope_grid_has_no_river_cells_below_threshold_by_default() {
|
||||
// Small 8×4 grid: max flow_accum ≤ 32, below RIVER_THRESHOLD (200).
|
||||
let elev = slope_grid(8, 4);
|
||||
let result = analyze(&elev, 8, 4, 0.3);
|
||||
// River cells may be empty on this tiny grid — that is acceptable.
|
||||
// What matters: no panic and basin count ≥ 1.
|
||||
assert!(!result.drainage_basins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_grid_river_cells_nonempty() {
|
||||
// 512×256: max flow accumulation ~131K >> RIVER_THRESHOLD.
|
||||
let elev = slope_grid(512, 256);
|
||||
let result = analyze(&elev, 512, 256, 0.3);
|
||||
assert!(
|
||||
!result.river_network.river_cells.is_empty(),
|
||||
"Expected river cells on a large sloped grid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basin_area_pcts_sum_to_one() {
|
||||
let elev = slope_grid(64, 32);
|
||||
let result = analyze(&elev, 64, 32, 0.3);
|
||||
let total: f32 = result.drainage_basins.iter().map(|b| b.area_pct).sum();
|
||||
assert!(
|
||||
(total - 1.0).abs() < 0.01,
|
||||
"Basin area fractions must sum to 1, got {}",
|
||||
total
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basin_count_within_target_range() {
|
||||
let elev = slope_grid(128, 64);
|
||||
let result = analyze(&elev, 128, 64, 0.3);
|
||||
let n = result.drainage_basins.len();
|
||||
assert!(
|
||||
n >= 1 && n <= 12,
|
||||
"Basin count {} out of expected range [1, 12]",
|
||||
n
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism() {
|
||||
// Running analyze twice on the same input must produce identical results.
|
||||
let elev = slope_grid(64, 32);
|
||||
let r1 = analyze(&elev, 64, 32, 0.3);
|
||||
let r2 = analyze(&elev, 64, 32, 0.3);
|
||||
assert_eq!(
|
||||
r1.river_network.river_cells, r2.river_network.river_cells,
|
||||
"River cells must be deterministic"
|
||||
);
|
||||
assert_eq!(
|
||||
r1.drainage_basins.len(),
|
||||
r2.drainage_basins.len(),
|
||||
"Basin count must be deterministic"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Background generation queue — prioritized Rayon thread pool (D-206).
|
||||
//!
|
||||
//! All runtime-background generation work runs through this queue. The main
|
||||
//! tick thread submits work items (non-blocking) and drains completion events
|
||||
//! once per tick via a `crossbeam` channel.
|
||||
//!
|
||||
//! **Priority levels (D-206):**
|
||||
//! - `Immediate`: player arrives within 1 game-minute. Runs first.
|
||||
//! - `High`: player arrives within 5 game-minutes.
|
||||
//! - `Medium`: player is in the same system.
|
||||
//! - `Low`: player has heard of this location via NPC/news.
|
||||
//!
|
||||
//! **Work item types (D-206):**
|
||||
//! - `AnalyzeBody`: D8 drainage + attractor extraction for a body.
|
||||
//! - `GenerateSkeleton`: Phase 1 DistrictSkeleton for a city.
|
||||
//! - `FillChunk`: Phase 2 chunk fill for a pre-loaded district.
|
||||
//!
|
||||
//! Completion events are delivered to the main thread via
|
||||
//! `GenerationQueue::drain_completions()`, called once per tick from a Bevy
|
||||
//! system in `TickPhase::PreInput`.
|
||||
//!
|
||||
//! **Thread count (D-206):** `available_parallelism - 2`, minimum 1.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bevy_ecs::prelude::Resource;
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Priority
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Work priority levels — lower discriminant = higher priority.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum GenPriority {
|
||||
/// Player arrives within ~1 game-minute. Runs before all other levels.
|
||||
Immediate = 0,
|
||||
/// Player arrives within ~5 game-minutes.
|
||||
High = 1,
|
||||
/// Player is in the same system.
|
||||
Medium = 2,
|
||||
/// Player has seen or heard of this location (NPC dialogue, news ticker).
|
||||
Low = 3,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Work item types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A unit of background generation work (D-206).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GenWorkItem {
|
||||
/// Run D8 drainage analysis + attractor extraction for this body.
|
||||
AnalyzeBody { body_id: String },
|
||||
/// Generate a Phase 1 DistrictSkeleton for this city.
|
||||
GenerateSkeleton { city_id: u64 },
|
||||
/// Pre-fill a chunk in an existing district.
|
||||
FillChunk {
|
||||
district_id: u64,
|
||||
block_pos: (u32, u32),
|
||||
},
|
||||
}
|
||||
|
||||
impl GenWorkItem {
|
||||
pub fn body_id(&self) -> Option<&str> {
|
||||
if let GenWorkItem::AnalyzeBody { body_id } = self {
|
||||
Some(body_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Completion event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sent back to the main thread when a work item finishes (D-206).
|
||||
#[derive(Debug)]
|
||||
pub enum GenCompletion {
|
||||
BodyAnalyzed {
|
||||
body_id: String,
|
||||
},
|
||||
SkeletonGenerated {
|
||||
city_id: u64,
|
||||
},
|
||||
ChunkFilled {
|
||||
district_id: u64,
|
||||
block_pos: (u32, u32),
|
||||
},
|
||||
/// Work item failed — body_id or city_id for logging.
|
||||
Failed {
|
||||
item: GenWorkItem,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal queued work
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct QueuedWork {
|
||||
priority: GenPriority,
|
||||
item: GenWorkItem,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GenerationQueue — Bevy Resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy `Resource` managing the background generation queue (D-206).
|
||||
///
|
||||
/// Submit work with `submit()`. Drain completions with `drain_completions()`
|
||||
/// once per tick. The Rayon thread pool runs tasks in priority order.
|
||||
///
|
||||
/// Priority is respected because `dispatch_next()` is gated on pool saturation
|
||||
/// via `in_flight_count`: it only dispatches when fewer than `n_threads` tasks
|
||||
/// are running. This applies to all work item types — `in_flight` (body-id set)
|
||||
/// is only for AnalyzeBody dedup; `in_flight_count` is the general saturation gate.
|
||||
#[derive(Resource)]
|
||||
pub struct GenerationQueue {
|
||||
/// Pending work items, sorted by priority (index 0 = highest priority).
|
||||
pending: Arc<Mutex<Vec<QueuedWork>>>,
|
||||
/// Completions channel — background tasks send here; main thread reads.
|
||||
completion_tx: Sender<GenCompletion>,
|
||||
completion_rx: Receiver<GenCompletion>,
|
||||
/// Rayon thread pool dedicated to generation work.
|
||||
pool: rayon::ThreadPool,
|
||||
/// Set of body_ids currently in-flight — used only for AnalyzeBody dedup.
|
||||
in_flight: Arc<Mutex<std::collections::BTreeSet<String>>>,
|
||||
/// Count of all work items currently executing in the Rayon pool.
|
||||
/// This is the saturation gate — all work item types increment/decrement it.
|
||||
in_flight_count: Arc<Mutex<usize>>,
|
||||
/// Thread count — caps concurrent dispatches so pending items accumulate
|
||||
/// and priority ordering is consulted before the pool has free threads.
|
||||
n_threads: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GenerationQueue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let pending_len = self.pending.lock().map(|p| p.len()).unwrap_or(0);
|
||||
f.debug_struct("GenerationQueue")
|
||||
.field("pending_count", &pending_len)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerationQueue {
|
||||
/// Create a new queue with the D-206 thread count:
|
||||
/// `available_parallelism - 2`, minimum 1.
|
||||
pub fn new() -> Self {
|
||||
let n_threads = std::thread::available_parallelism()
|
||||
.map(|p| p.get().saturating_sub(2).max(1))
|
||||
.unwrap_or(1);
|
||||
Self::with_threads(n_threads)
|
||||
}
|
||||
|
||||
/// Create a queue with a specific thread count (for testing).
|
||||
pub fn with_threads(n_threads: usize) -> Self {
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(n_threads)
|
||||
.thread_name(|i| format!("gen-worker-{i}"))
|
||||
.build()
|
||||
.expect("failed to build generation rayon pool");
|
||||
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
|
||||
Self {
|
||||
pending: Arc::new(Mutex::new(Vec::new())),
|
||||
completion_tx: tx,
|
||||
completion_rx: rx,
|
||||
pool,
|
||||
in_flight: Arc::new(Mutex::new(std::collections::BTreeSet::new())),
|
||||
in_flight_count: Arc::new(Mutex::new(0)),
|
||||
n_threads,
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit a work item at the given priority.
|
||||
///
|
||||
/// If an `AnalyzeBody` item for the same body_id is already in-flight or
|
||||
/// pending, the submission is silently ignored (idempotent).
|
||||
pub fn submit(&self, item: GenWorkItem, priority: GenPriority) {
|
||||
// Dedup AnalyzeBody submissions.
|
||||
if let Some(body_id) = item.body_id() {
|
||||
let in_flight = self.in_flight.lock().unwrap();
|
||||
if in_flight.contains(body_id) {
|
||||
return;
|
||||
}
|
||||
drop(in_flight);
|
||||
// Check pending list.
|
||||
let pending = self.pending.lock().unwrap();
|
||||
if pending.iter().any(|q| q.item.body_id() == Some(body_id)) {
|
||||
return;
|
||||
}
|
||||
drop(pending);
|
||||
}
|
||||
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
let pos = pending
|
||||
.iter()
|
||||
.position(|q| q.priority > priority)
|
||||
.unwrap_or(pending.len());
|
||||
pending.insert(pos, QueuedWork { priority, item });
|
||||
drop(pending);
|
||||
|
||||
self.dispatch_next();
|
||||
}
|
||||
|
||||
/// Drain all completed items from the channel and dispatch pending work.
|
||||
///
|
||||
/// Call once per tick from the main thread. Returns all completions
|
||||
/// available without blocking. After draining, dispatches as many pending
|
||||
/// items as there are free thread slots — this is the point where priority
|
||||
/// ordering matters, since the pool was saturated when items were submitted.
|
||||
pub fn drain_completions(&self) -> Vec<GenCompletion> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(c) = self.completion_rx.try_recv() {
|
||||
out.push(c);
|
||||
}
|
||||
// Fill any newly-freed slots.
|
||||
for _ in 0..out.len() {
|
||||
self.dispatch_next();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Number of items waiting in the pending queue.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
self.pending.lock().unwrap().len()
|
||||
}
|
||||
|
||||
// Dispatch the highest-priority pending item to the Rayon pool.
|
||||
//
|
||||
// Gated on in_flight_count < n_threads — applies to all work item types,
|
||||
// not just AnalyzeBody. When the pool is full, items stay in the sorted
|
||||
// pending Vec so priority ordering is consulted on the next free slot.
|
||||
fn dispatch_next(&self) {
|
||||
let item = {
|
||||
let count = self.in_flight_count.lock().unwrap();
|
||||
if *count >= self.n_threads {
|
||||
return;
|
||||
}
|
||||
drop(count);
|
||||
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
pending.remove(0).item
|
||||
};
|
||||
|
||||
// Mark body as in-flight (AnalyzeBody dedup).
|
||||
if let Some(body_id) = item.body_id() {
|
||||
self.in_flight.lock().unwrap().insert(body_id.to_string());
|
||||
}
|
||||
// Increment general in-flight counter for all item types.
|
||||
*self.in_flight_count.lock().unwrap() += 1;
|
||||
|
||||
let tx = self.completion_tx.clone();
|
||||
let in_flight = Arc::clone(&self.in_flight);
|
||||
let in_flight_count = Arc::clone(&self.in_flight_count);
|
||||
|
||||
self.pool.spawn(move || {
|
||||
let completion = run_work_item(&item);
|
||||
|
||||
// Un-mark body dedup set (AnalyzeBody only).
|
||||
if let Some(body_id) = item.body_id() {
|
||||
in_flight.lock().unwrap().remove(body_id);
|
||||
}
|
||||
// Decrement general counter for all item types.
|
||||
*in_flight_count.lock().unwrap() -= 1;
|
||||
|
||||
let _ = tx.send(completion);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GenerationQueue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Work execution stub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Execute one work item. This is the Rayon task body.
|
||||
///
|
||||
/// Currently a stub — real implementations will call `drainage::analyze()`,
|
||||
/// the attractor pipeline, and the district skeleton generator. Stubs return
|
||||
/// immediate success to allow the queue infrastructure to be tested independently.
|
||||
fn run_work_item(item: &GenWorkItem) -> GenCompletion {
|
||||
match item {
|
||||
GenWorkItem::AnalyzeBody { body_id } => GenCompletion::BodyAnalyzed {
|
||||
body_id: body_id.clone(),
|
||||
},
|
||||
GenWorkItem::GenerateSkeleton { city_id } => {
|
||||
GenCompletion::SkeletonGenerated { city_id: *city_id }
|
||||
}
|
||||
GenWorkItem::FillChunk {
|
||||
district_id,
|
||||
block_pos,
|
||||
} => GenCompletion::ChunkFilled {
|
||||
district_id: *district_id,
|
||||
block_pos: *block_pos,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn make_queue() -> GenerationQueue {
|
||||
GenerationQueue::with_threads(2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_and_drain() {
|
||||
let q = make_queue();
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "TestBody".to_string(),
|
||||
},
|
||||
GenPriority::Medium,
|
||||
);
|
||||
// Give Rayon time to complete the (stub) task.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let completions = q.drain_completions();
|
||||
assert_eq!(completions.len(), 1);
|
||||
assert!(matches!(
|
||||
&completions[0],
|
||||
GenCompletion::BodyAnalyzed { body_id } if body_id == "TestBody"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_analyze_body() {
|
||||
let q = make_queue();
|
||||
// Submit the same body twice before it can complete.
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "Dup".to_string(),
|
||||
},
|
||||
GenPriority::Low,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "Dup".to_string(),
|
||||
},
|
||||
GenPriority::Low,
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let completions = q.drain_completions();
|
||||
// Should have completed exactly once.
|
||||
assert_eq!(completions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_ordering() {
|
||||
// Submit three items rapidly; Immediate should be dispatched first.
|
||||
// Uses 3 threads so all items can dispatch without hitting saturation.
|
||||
let q = GenerationQueue::with_threads(3);
|
||||
// Using GenerateSkeleton (no dedup logic) to test ordering directly.
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 1 },
|
||||
GenPriority::Low,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 2 },
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 3 },
|
||||
GenPriority::Medium,
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let completions = q.drain_completions();
|
||||
assert_eq!(completions.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_ordering_respected_under_saturation() {
|
||||
// Single-thread queue: in_flight_count saturates at 1, so the second
|
||||
// item stays in the pending Vec and is dispatched in priority order.
|
||||
// Uses AnalyzeBody (distinct body_ids) so all paths — dedup set AND
|
||||
// in_flight_count — are exercised.
|
||||
let q = GenerationQueue::with_threads(1);
|
||||
// Submit Low first, then Immediate. With 1 thread:
|
||||
// - "BodyA" (Low) dispatches immediately (pool empty).
|
||||
// - "BodyB" (Immediate) is inserted at index 0 of the sorted pending
|
||||
// Vec while "BodyA" is in-flight (in_flight_count = 1 = n_threads).
|
||||
// - When "BodyA" completes, drain_completions() calls dispatch_next()
|
||||
// which picks index 0 = "BodyB" (Immediate).
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "BodyA".to_string(),
|
||||
},
|
||||
GenPriority::Low,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::AnalyzeBody {
|
||||
body_id: "BodyB".to_string(),
|
||||
},
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
// Wait for BodyA to complete.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
// drain_completions dispatches BodyB (Immediate, index 0 of pending).
|
||||
let first = q.drain_completions();
|
||||
// Wait for BodyB to complete.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let second = q.drain_completions();
|
||||
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(second.len(), 1);
|
||||
assert!(matches!(&first[0], GenCompletion::BodyAnalyzed { body_id } if body_id == "BodyA"));
|
||||
assert!(
|
||||
matches!(&second[0], GenCompletion::BodyAnalyzed { body_id } if body_id == "BodyB")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_empty_returns_empty() {
|
||||
let q = make_queue();
|
||||
let result = q.drain_completions();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_count_decreases_after_completion() {
|
||||
let q = make_queue();
|
||||
q.submit(
|
||||
GenWorkItem::FillChunk {
|
||||
district_id: 99,
|
||||
block_pos: (0, 0),
|
||||
},
|
||||
GenPriority::High,
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let completions = q.drain_completions();
|
||||
assert!(!completions.is_empty() || q.pending_count() == 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Heightmap BLOB loader — reads float32 LE elevation grids from systems.db.
|
||||
//!
|
||||
//! Implements the Rust side of D-202. The Python pipeline stores each body's
|
||||
//! elevation grid as a contiguous float32 little-endian BLOB in
|
||||
//! `atlas_body_heightmaps.data`. This module loads that BLOB via `rusqlite`
|
||||
//! and reinterprets the bytes into a `Vec<f32>` using `bytemuck`.
|
||||
//!
|
||||
//! Values are normalized elevation in [0.0, 1.0]. `sea_level` is the fraction
|
||||
//! below which terrain is underwater (0.0 = no ocean).
|
||||
//!
|
||||
//! Canonical grid size: 512 × 256 (GRID_W × GRID_H), row-major.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Canonical grid dimensions matching the Python pipeline (generate_atlas.py).
|
||||
pub const GRID_W: u32 = 512;
|
||||
pub const GRID_H: u32 = 256;
|
||||
|
||||
/// A loaded heightmap for one planetary body.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BodyHeightmap {
|
||||
pub body_id: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Row-major elevation values, normalized to [0.0, 1.0].
|
||||
pub data: Vec<f32>,
|
||||
/// Elevation fraction below which terrain is ocean/sea.
|
||||
pub sea_level: f32,
|
||||
}
|
||||
|
||||
impl BodyHeightmap {
|
||||
/// Returns the elevation at (row, col), or `None` if out of bounds.
|
||||
#[inline]
|
||||
pub fn get(&self, row: u32, col: u32) -> Option<f32> {
|
||||
if row < self.height && col < self.width {
|
||||
Some(self.data[(row * self.width + col) as usize])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the cell at (row, col) is land (above sea level).
|
||||
#[inline]
|
||||
pub fn is_land(&self, row: u32, col: u32) -> bool {
|
||||
self.get(row, col).is_some_and(|e| e >= self.sea_level)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HeightmapLoadError {
|
||||
#[error("no heightmap row for body '{0}'")]
|
||||
NotFound(String),
|
||||
#[error("BLOB size {actual} does not match declared grid {w}×{h}×4 = {expected}")]
|
||||
BlobSizeMismatch {
|
||||
actual: usize,
|
||||
w: u32,
|
||||
h: u32,
|
||||
expected: usize,
|
||||
},
|
||||
#[error("SQLite error: {0}")]
|
||||
Sql(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
/// Load the heightmap for `body_id` from the open `conn`.
|
||||
///
|
||||
/// The BLOB is reinterpreted in-place via `bytemuck::cast_slice` — no copy
|
||||
/// beyond the initial `Vec<u8>` read from SQLite. On little-endian hosts
|
||||
/// (all current targets) this is a zero-cost reinterpret. On big-endian hosts
|
||||
/// the bytes are already stored LE, so each f32 would be byte-swapped; this
|
||||
/// function does not perform that swap — big-endian support is deferred.
|
||||
pub fn load_heightmap(
|
||||
conn: &Connection,
|
||||
body_id: &str,
|
||||
) -> Result<BodyHeightmap, HeightmapLoadError> {
|
||||
let result = conn.query_row(
|
||||
"SELECT width, height, data, sea_level \
|
||||
FROM atlas_body_heightmaps WHERE body_id = ?1",
|
||||
params![body_id],
|
||||
|row| {
|
||||
let width: u32 = row.get(0)?;
|
||||
let height: u32 = row.get(1)?;
|
||||
let blob: Vec<u8> = row.get(2)?;
|
||||
let sea_level: f64 = row.get(3)?;
|
||||
Ok((width, height, blob, sea_level as f32))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
Err(HeightmapLoadError::NotFound(body_id.to_string()))
|
||||
}
|
||||
Err(e) => Err(HeightmapLoadError::Sql(e)),
|
||||
Ok((width, height, blob, sea_level)) => {
|
||||
let expected = (width * height * 4) as usize;
|
||||
if blob.len() != expected {
|
||||
return Err(HeightmapLoadError::BlobSizeMismatch {
|
||||
actual: blob.len(),
|
||||
w: width,
|
||||
h: height,
|
||||
expected,
|
||||
});
|
||||
}
|
||||
// Reinterpret the LE bytes as f32 values. bytemuck::cast_slice
|
||||
// is safe here: we verified the length is a multiple of 4, and
|
||||
// f32 has no invalid bit patterns.
|
||||
let floats: &[f32] = bytemuck::cast_slice(&blob);
|
||||
let data = floats.to_vec();
|
||||
Ok(BodyHeightmap {
|
||||
body_id: body_id.to_string(),
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
sea_level,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn make_test_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE atlas_body_heightmaps (
|
||||
body_id TEXT PRIMARY KEY,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
sea_level REAL NOT NULL DEFAULT 0.0,
|
||||
imported_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_heightmap(conn: &Connection, body_id: &str, w: u32, h: u32, sea_level: f32) {
|
||||
let floats: Vec<f32> = (0..(w * h)).map(|i| i as f32 / (w * h) as f32).collect();
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&floats);
|
||||
conn.execute(
|
||||
"INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![body_id, w, h, bytes, sea_level],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_canonical_size() {
|
||||
let conn = make_test_db();
|
||||
insert_heightmap(&conn, "TestBody", GRID_W, GRID_H, 0.3);
|
||||
let hm = load_heightmap(&conn, "TestBody").unwrap();
|
||||
assert_eq!(hm.width, GRID_W);
|
||||
assert_eq!(hm.height, GRID_H);
|
||||
assert_eq!(hm.data.len(), (GRID_W * GRID_H) as usize);
|
||||
assert!((hm.sea_level - 0.3).abs() < 1e-6);
|
||||
// First cell is 0.0, last approaches 1.0
|
||||
assert_eq!(hm.data[0], 0.0);
|
||||
assert!(hm.data.last().copied().unwrap() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_and_is_land() {
|
||||
let conn = make_test_db();
|
||||
insert_heightmap(&conn, "LandBody", 4, 2, 0.5);
|
||||
let hm = load_heightmap(&conn, "LandBody").unwrap();
|
||||
// First cell (index 0) = 0.0 / 8 = 0.0 — below sea level
|
||||
assert!(!hm.is_land(0, 0));
|
||||
// Last cell (index 7) = 7.0 / 8 = 0.875 — above sea level
|
||||
assert!(hm.is_land(1, 3));
|
||||
// Out-of-bounds returns false
|
||||
assert!(!hm.is_land(99, 99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_error() {
|
||||
let conn = make_test_db();
|
||||
let err = load_heightmap(&conn, "Ghost").unwrap_err();
|
||||
assert!(matches!(err, HeightmapLoadError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_size_mismatch_error() {
|
||||
let conn = make_test_db();
|
||||
// Insert a truncated BLOB
|
||||
conn.execute(
|
||||
"INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level)
|
||||
VALUES ('BadBlob', 4, 4, X'DEADBEEF', 0.0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_heightmap(&conn, "BadBlob").unwrap_err();
|
||||
assert!(matches!(err, HeightmapLoadError::BlobSizeMismatch { .. }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Atlas data loaders — reads pre-computed build-time data from systems.db.
|
||||
//!
|
||||
//! These loaders are used by the runtime-background tier (D-200, D-206) when
|
||||
//! populating BodyWorldState (D-203). They are never called on the main tick thread.
|
||||
|
||||
pub mod attractor_matching;
|
||||
pub mod block_irregularity;
|
||||
pub mod body_world_state;
|
||||
pub mod district_mix;
|
||||
pub mod drainage;
|
||||
pub mod gen_queue;
|
||||
pub mod heightmap;
|
||||
pub mod rng;
|
||||
pub mod skeleton_gen;
|
||||
pub mod tile_condition;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user