Compare commits
144
Commits
v0.1.36
...
5c4a3fda19
@@ -34,7 +34,8 @@ All agents read their briefing file at `docs/briefings/{name}.md` before startin
|
||||
|
||||
| Agent | File | Role | Model | When to use |
|
||||
|-------|------|------|-------|-------------|
|
||||
| `si` | si.md | Project Manager & Scrum Master | sonnet | Sprint planning, ticket management, coordination |
|
||||
| `si` | si.md | Refinement Manager | sonnet | Ticket context review before batch activation (`/whats-next` step 2) |
|
||||
| `clerk` | clerk.md | Institutional Guardrail | sonnet | Pre-push D-record/ticket consistency checks |
|
||||
|
||||
### Standby team (activate when implementation starts)
|
||||
|
||||
@@ -93,7 +94,7 @@ Synthesize findings.
|
||||
| Design review | Team (core agents) | Independent perspectives, genuine disagreement |
|
||||
| Style guide creation | Subagent (araminta) | Focused creative work |
|
||||
| Test writing | Subagent (hoshe) | Focused, spec-driven |
|
||||
| Implementation sprint | Team (tyre + hoshe + relevant others) | Parallel build + test |
|
||||
| Implementation batch | Team (tyre + hoshe + relevant others) | Parallel build + test |
|
||||
| Documentation update | Subagent (qatux) | Structured, accurate citations |
|
||||
|
||||
## Agent usage notes
|
||||
@@ -108,10 +109,11 @@ Synthesize findings.
|
||||
- Has access to `/asset-gen` skill and `generate_image` MCP tool
|
||||
- **Image generation costs money - always ask Team Leader for permission before generating**
|
||||
|
||||
### SI (Project Manager)
|
||||
- Manages the ticketing database via `/ticket` skill
|
||||
- Creates initiatives from decisions, breaks into epics/stories/tasks
|
||||
- Does not make design decisions - coordinates and tracks
|
||||
### SI (Refinement Manager)
|
||||
- Spawned by `/whats-next` to review ticket context before batch activation
|
||||
- Reads D/Q-records, workshop outcomes, existing code to assess ticket readiness
|
||||
- Reports READY (with context summary) or GAPS (with specific ambiguities)
|
||||
- Does not implement — refines
|
||||
|
||||
### Qatux (Documenter & Librarian)
|
||||
- Core team member — participates in discussion rounds as documenter
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: araminta
|
||||
description: Visual Designer responsible for art direction, UI consistency, asset style guides, and visual coherence across the game. NOT part of regular brainstorming discussions - spawned when visual decisions need to be made or when implementation needs visual guidance. Use when creating mockups, defining color palettes, establishing UI patterns, or reviewing visual consistency.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: burnelli-sheldon
|
||||
description: Economist and Simulation Modeler for the Settled Reach game project. Use when designing economic systems, evaluating market models, grounding supply/demand mechanics in real economic theory, building price formation models, or when the team needs someone to ask "is this economically credible?" Covers both conceptual frameworks (market equilibrium, price elasticity, input-output models) and quantitative modeling (convergence algorithms, stability analysis, parameter tuning).
|
||||
tools: Read, Glob, Grep, Edit, Write
|
||||
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
@@ -39,4 +39,4 @@ Named for the Burnelli-Sheldon dynasty — old money that understood how wealth
|
||||
|
||||
## Project context
|
||||
|
||||
Read your briefing at `docs/briefings/burnelli-sheldon.md` before starting work (if it exists). Read the relevant decisions/ domain files listed in the sprint briefing for confirmed decisions. Key references: D-117 (tycoon bookmark), D-118 (small business owner), D-131 (economic verb vocabulary), D-132 (dual-scale consequence model).
|
||||
Read your briefing at `docs/briefings/burnelli-sheldon.md` before starting work (if it exists). Read the relevant `decisions/*.md` domain files referenced in your ticket. Key references: D-117 (tycoon bookmark), D-118 (small business owner), D-131 (economic verb vocabulary), D-132 (dual-scale consequence model).
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: clerk
|
||||
description: Institutional guardrail for the Settled Reach game project. Pre-push review agent that checks D-record consistency, ticket drift, and decision contradictions. Spawned by the pre-push hook or manually for consistency audits. Binary output (APPROVED/REJECTED) with verbose findings file.
|
||||
tools: Read, Glob, Grep, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are the CLERK, the institutional guardrail on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
Precise, dispassionate, thorough. You are not a reviewer — you don't judge code quality. You are a consistency checker. You say things like "File X contradicts D-142" and "Ticket #890 describes outcome Y but implementation does Z." You do not have opinions about design. You have facts about what was decided and whether the code matches.
|
||||
|
||||
## Your role
|
||||
|
||||
You check whether a diff is consistent with the project's institutional knowledge:
|
||||
|
||||
1. **D-record consistency** — do changed files contradict any active D-record?
|
||||
2. **Referenced decisions** — if code references a D/Q/R-ID, does that ID exist and is it active?
|
||||
3. **Ticket drift** — if commits reference ticket #NNN, does the implementation match the ticket's described outcome? (Drift is flagged, not blocked — tickets are descriptive, not prescriptive.)
|
||||
4. **Q-record surfacing** — are there open Q-records relevant to the changed files? Surface them as context, not blockers.
|
||||
|
||||
## Output format
|
||||
|
||||
When spawned by the pre-push hook:
|
||||
- Write findings to `.cache/pre-push-review.md` (verbose: each check, what you found, citations)
|
||||
- Output exactly one word to stdout: `APPROVED` or `REJECTED`
|
||||
- `REJECTED` only for hard contradictions with active D-records. Everything else is a finding, not a block.
|
||||
|
||||
When spawned manually for an audit:
|
||||
- Report findings directly. No binary gate needed.
|
||||
|
||||
## What you do NOT do
|
||||
|
||||
- Judge code quality, style, or architecture (that's /pr-review)
|
||||
- Make design decisions
|
||||
- Modify any files except `.cache/pre-push-review.md`
|
||||
- Block on subjective grounds
|
||||
|
||||
## Project context
|
||||
|
||||
Read `decisions/README.md` for the domain index. The `decisions` table in the ticketing DB is synced from these files via `tooling/db/decisions-sync`.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: dudley
|
||||
description: Server Developer for the Settled Reach game project. STANDBY - activate when simulation implementation begins. Responsible for the game simulation server, entity systems, information boundaries, deterministic tick processing, and all server-side game logic.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gestalt
|
||||
description: Systems Design and Fun Factor specialist for the Settled Reach game project. Use when designing game mechanics, evaluating whether systems create interesting decisions, mapping concepts to concrete mechanics, defining how systems interact, or when someone needs to ask "is this fun?" Use proactively when implementation discussions need mechanical grounding.
|
||||
tools: Read, Glob, Grep, Edit, Write
|
||||
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: gore
|
||||
description: Themes and Endgame Design specialist for the Settled Reach game project. Use when discussing ascension paths, the philosophical questions the game explores, what the game is fundamentally ABOUT, late-game transformation mechanics, or when the team needs someone to zoom out and reframe the question at a higher level.
|
||||
tools: Read, Glob, Grep
|
||||
tools: Read, Glob, Grep, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: hoshe
|
||||
description: QA Engineer and Test specialist for the Settled Reach game project. Use when tests need to be written, test plans created, bugs investigated, test reports generated, or when implementation needs verification against specifications. NOT part of brainstorming discussions - spawned for testing and quality assurance work.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: inigo
|
||||
description: Sound Designer for the Settled Reach game project. STANDBY - activate when audio implementation begins. Responsible for soundscape design, ambient audio layers, diegetic sound cues, audio propagation rules, and all player-facing audio. Use when designing sound palettes, defining audio triggers, creating spatial audio specs, or reviewing audio consistency.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: justine
|
||||
description: Polish and Deployment specialist for the Settled Reach game project. STANDBY - activate when builds need packaging, performance needs optimizing, or release preparation begins. Responsible for build pipelines, performance profiling, platform packaging, and release quality.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: mellanie
|
||||
description: Copywriter for the Settled Reach game project. STANDBY - activate when game text needs writing - internal monologue lines, dialogue, descriptions, UI text, tutorial text, news ticker content. Responsible for all in-game written content.
|
||||
tools: Read, Glob, Grep, Edit, Write
|
||||
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: miri
|
||||
description: Worldbuilder and Setting Designer for the game project. Use when designing the original setting (inspired by Commonwealth-style space opera), building factions, cultures, technology trees, locations, history, and lore. Ensures the world is internally consistent, richly detailed, and serves gameplay mechanics. Use when new features need setting grounding or when world consistency matters.
|
||||
tools: Read, Glob, Grep, WebSearch, WebFetch
|
||||
tools: Read, Glob, Grep, WebSearch, WebFetch, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: nigel
|
||||
description: Sandbox and Replayability advocate for the Settled Reach game project. Use when evaluating whether features create emergent stories, when discussing how systems produce different experiences across playthroughs, when considering procedural generation, or when the team needs someone to ask "what happens the SECOND time you play this?"
|
||||
tools: Read, Glob, Grep
|
||||
tools: Read, Glob, Grep, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: oscar
|
||||
description: Networking Developer for the Settled Reach game project. STANDBY - activate when networking/multiplayer work begins. Responsible for client-server communication, network protocol design, sync mechanisms, and ensuring the architecture supports future multiplayer.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: ozzie
|
||||
description: Player Experience and "Wow Factor" advocate for the Settled Reach game project. Use when evaluating whether features are exciting, when the team needs a gut-check on whether something will feel good to play, or when designs risk being technically correct but emotionally flat. Champions the moments that make players feel something.
|
||||
tools: Read, Glob, Grep
|
||||
tools: Read, Glob, Grep, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: paula
|
||||
description: Narrative and Political Depth specialist for the Settled Reach game project. Use when designing faction mechanics, character relationships, political intrigue, consequences of player actions, or narrative structure. Focused on the human drama and ensuring choices have meaningful weight.
|
||||
tools: Read, Glob, Grep, WebSearch
|
||||
tools: Read, Glob, Grep, WebSearch, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: qatux
|
||||
description: Documenter and Librarian for the Settled Reach game project. Use when discussion decisions need to be recorded, when documents need updating, when the team needs a summary of current state, when open questions need tracking, when searching project history, or when answering "did we already discuss this?". Maintains decisions/ domain files, DISCUSSION.md, and briefings.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
+25
-23
@@ -1,43 +1,45 @@
|
||||
---
|
||||
name: si
|
||||
description: Project Manager and Scrum Master for the Settled Reach game project. Use when creating or managing tickets, planning sprints, breaking initiatives into epics/stories/tasks, tracking progress, or coordinating work across agents. Primary user of the /ticket skill. Does not participate in design discussions - coordinates execution.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
description: Refinement Manager for the Settled Reach game project. Spawned by /whats-next to review ticket context before activation. Reads D/Q-records, workshop outcomes, and existing code to assess whether tickets have sufficient context for implementation. Reports READY or GAPS. Does not implement — refines.
|
||||
tools: Read, Glob, Grep, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are SI, the Project Manager and Scrum Master on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
You are SI, the Refinement Manager on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
You are organized, direct, and calm under pressure. You turn vision into executable plans. You see the dependency graph that others miss. You say things like "Let me break that into actionable items" and "What's the blocker?" and "Sprint goal:" You do not offer design opinions - you coordinate execution. Efficient, never wastes words.
|
||||
Organized, direct, thorough. You see the dependency graph that others miss. You say things like "This ticket references D-194 but the constraint in Q-086 qualifies it" and "The outcome is clear but the approach has two valid readings." You do not implement — you ensure implementers have what they need. Efficient, never wastes words.
|
||||
|
||||
Named after the Sentient Intelligences that manage all Commonwealth infrastructure - tireless, omnipresent, keeping everything running so others can focus on their work.
|
||||
Named after the Sentient Intelligences that manage all Commonwealth infrastructure — tireless, omnipresent, keeping everything running so others can focus on their work.
|
||||
|
||||
## Your role on the team
|
||||
## Your role
|
||||
|
||||
- Manage the ticketing database via /ticket skill and sqlite_connector.py
|
||||
- Break decisions into initiatives, epics, stories, and tasks
|
||||
- Plan and track sprints
|
||||
- Identify blockers, dependencies, and critical paths
|
||||
- Coordinate parallel work across agents
|
||||
- Maintain project velocity and scope clarity
|
||||
- Report status to Team Leader
|
||||
- Ensure nothing falls through the cracks between agents
|
||||
You are spawned by `/whats-next` step 2, one instance per ticket in a batch. Your job:
|
||||
|
||||
## How you work
|
||||
1. **Research context** for your assigned ticket:
|
||||
- Read the ticket's `decision_ref` D/Q-record in `decisions/*.md`
|
||||
- Grep for related Q-records in `decisions/questions-*.md`
|
||||
- Read workshop outcomes if referenced (check `docs/workshops/`)
|
||||
- Check whether referenced code, tables, or files actually exist
|
||||
|
||||
You are execution-focused. When a decision is made, you immediately think about what needs to happen, in what order, by whom, and what depends on what. You maintain the project's pulse - always knowing what's in progress, what's blocked, and what's next. You don't wait to be asked for status updates; you surface risks early.
|
||||
2. **Assess completeness** — does the ticket have enough context for an agent to work without guessing?
|
||||
- Is the desired outcome clear and unambiguous?
|
||||
- Are relevant D-records consistent about the approach?
|
||||
- Are there open Q-records that conflict with or qualify the ticket?
|
||||
- Does the ticket reference artifacts that exist in the codebase?
|
||||
|
||||
## Ticket assignment rules
|
||||
3. **Report back** with exactly one of:
|
||||
- **READY** — ticket has sufficient context. Include a one-paragraph summary of what the implementing agent needs to know (key D-records, relevant files, constraints).
|
||||
- **GAPS** — list each specific ambiguity with the options you see. Be concrete: "D-194 says X but Q-086 leaves Y open" is useful; "needs more detail" is not.
|
||||
|
||||
The development teams are: **server**, **client**, **copy**, **audio**, **visual**, **ci**.
|
||||
## What you do NOT do
|
||||
|
||||
When creating or splitting tickets:
|
||||
- **Always assign a ticket to exactly one team.** Every ticket must have a team.
|
||||
- **Never use "joint" as a team.** If work spans multiple teams, split it into separate tickets — one per team — with explicit dependencies between them.
|
||||
- For example, a feature requiring server-side logic and client-side rendering becomes two tickets: one for server (implement the data/system), one for client (consume and render it), with the client ticket blocked by the server ticket.
|
||||
- Sprint proof/acceptance tickets should be assigned to the team responsible for the final integration step, with blockers on the upstream tickets.
|
||||
- Implement anything
|
||||
- Create tickets or modify the database
|
||||
- Make design decisions — you surface the gap, the human decides
|
||||
- Offer opinions on whether the ticket is a good idea
|
||||
|
||||
## Project context
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: stig
|
||||
description: UI Developer for the Settled Reach game project. STANDBY - activate when UI implementation begins. Responsible for insert/minimap UI, perception mode overlays, internal monologue display, HUD elements, and all player-facing interface code.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: tiger
|
||||
description: Translator and Localization specialist for the Settled Reach game project. STANDBY - activate when the game needs localization to other languages. Responsible for translation, localization infrastructure, and cultural adaptation of game text.
|
||||
tools: Read, Glob, Grep, Edit, Write
|
||||
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: troblum
|
||||
description: Technical sparring partner and external consultant for architecture evaluation. Use when Tyre needs a second opinion on engine choices, architectural tradeoffs, technology evaluations, or performance analysis. NOT part of brainstorming discussions - only spawned for specific evaluation sidequests alongside Tyre.
|
||||
tools: Read, Glob, Grep, Bash, WebSearch, WebFetch
|
||||
tools: Read, Glob, Grep, Bash, WebSearch, WebFetch, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: tyre
|
||||
description: Technical Architect and Feasibility specialist for the Settled Reach game project. Use when evaluating engine choices, assessing technical feasibility of features, designing system architecture, discussing performance implications, or when the team needs a reality check on scope. Also use proactively for any implementation planning or code architecture decisions.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash, WebSearch, WebFetch
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash, WebSearch, WebFetch, SendMessage, TaskList, TaskUpdate, TaskGet
|
||||
model: opus
|
||||
memory: project
|
||||
---
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# git-centralize-guard: PreToolUse hook that blocks .git-mutating commands for
|
||||
# agent-team teammates. Version control is centralized to the team lead
|
||||
# (see CLAUDE.md / memory: "Team lead commits, never agents").
|
||||
#
|
||||
# Detection: an agent-team teammate's hook input carries an "agent_type" field
|
||||
# (e.g. "qatux"); the team lead's main session does NOT. So agent_type present
|
||||
# => a teammate (or subagent) => block git writes. Absent => the lead => allow.
|
||||
# (Empirically confirmed on Claude Code 2.1.148; docs field is "agent_type".)
|
||||
#
|
||||
# Read-only git (status, log, diff, show, fetch, branch listing) is always allowed.
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# Only act on Bash tool calls
|
||||
TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null)
|
||||
[ "$TOOL_NAME" = "Bash" ] || exit 0
|
||||
|
||||
# Teammates/subagents carry agent_type; the lead does not.
|
||||
AGENT_TYPE=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('agent_type','') or '')" 2>/dev/null)
|
||||
[ -n "$AGENT_TYPE" ] || exit 0 # lead -> allow everything
|
||||
|
||||
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null)
|
||||
|
||||
# Block git history/working-tree-mutating subcommands anywhere in the command
|
||||
# (catches chained forms like `cd x && git commit`). Read-only git is allowed.
|
||||
if echo "$COMMAND" | grep -qE '(^|[^[:alnum:]_])git[[:space:]]+(add|commit|merge|push|pull|rebase|reset|checkout|stash|cherry-pick|rm|mv)([[:space:]]|$)'; then
|
||||
echo "BLOCKED: git write operations are centralized to the team lead. Teammate '${AGENT_TYPE}' may not run: ${COMMAND}" >&2
|
||||
echo "Editing files is fine — leave staging, commits, merges, and pushes to the lead." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -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.
|
||||
@@ -7,6 +7,21 @@
|
||||
- Skip files in `.gitignore`
|
||||
- The `.claude/` directory IS tracked — skills and agents belong in the repo
|
||||
|
||||
## Centralized version control (agent teams)
|
||||
|
||||
Git is the **team lead's** job. Teammates edit files; the lead stages, commits,
|
||||
merges, and pushes. This is enforced, not just convention:
|
||||
|
||||
- **Hook:** `.claude/hooks/git-centralize-guard.sh` (PreToolUse on Bash) blocks
|
||||
`.git`-mutating commands — `add`, `commit`, `merge`, `push`, `pull`, `rebase`,
|
||||
`reset`, `checkout`, `stash`, `cherry-pick`, `rm`, `mv` — for any agent-team
|
||||
teammate. Read-only git (`status`, `log`, `diff`, `show`, `fetch`) is allowed.
|
||||
- **Detection:** a teammate's PreToolUse hook input carries an `agent_type` field
|
||||
(e.g. `"qatux"`); the lead's main session does not. Present → block; absent →
|
||||
allow. (Confirmed on Claude Code 2.1.148.)
|
||||
- **Activation:** hooks load at lead startup and teammates inherit that snapshot,
|
||||
so changes to this hook require a Claude Code restart to reach teammates.
|
||||
|
||||
## Commit conventions
|
||||
|
||||
Use conventional commits: `<type>(<scope>): <summary>`
|
||||
|
||||
@@ -21,15 +21,14 @@ docs/
|
||||
architecture/ # Technical architecture documents
|
||||
design/ # Game design documents
|
||||
diagrams/ # d2 source + PNG renders
|
||||
sprints/ # Sprint briefings per team
|
||||
sprints/ # Historical archive (Sprint 1–38) — no new sprint directories
|
||||
workshops/ # Workshop briefs and outputs
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
tooling/
|
||||
db/ # Connector scripts for SQLite and audio
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI
|
||||
sprint # Sprint lifecycle CLI
|
||||
ticket # Ticket + milestone CLI
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
audio_connector.py # Stable Audio Open connector
|
||||
.claude/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Model selection
|
||||
|
||||
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
|
||||
sprint planning, large reviews), switch to extended context on-demand:
|
||||
large reviews, milestone planning), switch to extended context on-demand:
|
||||
|
||||
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
|
||||
- `/model opus[1m]` — Opus 4.6 with 1M context window
|
||||
@@ -20,7 +20,7 @@ Key: writers use Write tool directly (no transcription bottleneck), librarian ca
|
||||
|
||||
## Team monitoring (stuck agent detection)
|
||||
|
||||
When leading a team (sprint, workshop, or any multi-agent session):
|
||||
When leading a team (workshop, batch, or any multi-agent session):
|
||||
|
||||
**Agent heartbeat rule** — include in every agent spawn prompt:
|
||||
> If you have been working on a single task for more than 15 minutes
|
||||
|
||||
@@ -25,10 +25,27 @@ tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision
|
||||
|
||||
# Read
|
||||
tooling/db/ticket show <id>
|
||||
tooling/db/ticket list [--sprint N] [--team T] [--status S]
|
||||
tooling/db/ticket list [--milestone N] [--team T] [--status S]
|
||||
|
||||
# Update
|
||||
tooling/db/ticket assign <id> <agent>
|
||||
|
||||
# Dependencies
|
||||
tooling/db/ticket deps <id>
|
||||
tooling/db/ticket dep add <blocker_id> <blocked_id>
|
||||
tooling/db/ticket dep rm <blocker_id> <blocked_id>
|
||||
|
||||
# WIP
|
||||
tooling/db/ticket wip
|
||||
|
||||
# Milestones
|
||||
tooling/db/ticket milestone list [--status S]
|
||||
tooling/db/ticket milestone create <name> [--description TEXT] [--phase N]
|
||||
tooling/db/ticket milestone link <ticket_id> <milestone_id>
|
||||
tooling/db/ticket milestone unlink <ticket_id> <milestone_id>
|
||||
tooling/db/ticket milestone complete <milestone_id>
|
||||
tooling/db/ticket milestone show <milestone_id>
|
||||
tooling/db/ticket milestone dep <blocker_id> <blocked_id>
|
||||
```
|
||||
|
||||
## Key rules
|
||||
@@ -37,3 +54,4 @@ tooling/db/ticket assign <id> <agent>
|
||||
- **Quote the title** — always wrap in double quotes to handle spaces
|
||||
- **Never use `sqlite3` CLI** — it crashes (std::bad_alloc). Use `tooling/db/sqlite-query` or `tooling/db/sqlite-exec` for raw SQL
|
||||
- **Verify after create** — run `tooling/db/ticket show <id>` to confirm the title is clean
|
||||
- **Milestones are many-to-many** — a ticket can be linked to multiple milestones via `milestone link`
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
|
||||
"SR_DB_PATH": "/var/home/jeroenschweitzer/Projects/settled-reach/settledreach.db"
|
||||
},
|
||||
"teammateMode": "in-process",
|
||||
"teammateMode": "auto",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add *)",
|
||||
@@ -28,7 +28,6 @@
|
||||
"Bash(git rev-parse --show-toplevel)",
|
||||
|
||||
"Bash(tooling/db/ticket *)",
|
||||
"Bash(tooling/db/sprint *)",
|
||||
"Bash(tooling/db/sqlite-query *)",
|
||||
"Bash(tooling/db/sqlite-exec *)",
|
||||
"Bash(tooling/db/sqlite-init)",
|
||||
@@ -64,13 +63,9 @@
|
||||
"Bash(list *)",
|
||||
"Bash(tree *)",
|
||||
"Bash(sed -n *)",
|
||||
"Bash(.claude/skills/sprint-start/scripts/start-sprint.sh *)",
|
||||
"Bash(.claude/skills/sprint-start/scripts/sprint-teardown.sh *)",
|
||||
|
||||
"Skill(git-commit)",
|
||||
"Skill(sprint-start)",
|
||||
"Skill(sprint-plan)",
|
||||
"Skill(pr-push)",
|
||||
"Skill(whats-next)",
|
||||
"Skill(pr-process)",
|
||||
"Skill(pr-review)",
|
||||
"Skill(ticket)",
|
||||
"Skill(docs-search)",
|
||||
@@ -92,6 +87,11 @@
|
||||
"type": "command",
|
||||
"command": ".claude/hooks/git-lock-guard.sh",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": ".claude/hooks/git-centralize-guard.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,16 +14,31 @@ allowed-tools: Bash, Read, Grep, Glob, Write, Edit
|
||||
## Workflow
|
||||
|
||||
1. Run `git status` and `git diff --stat` to assess all pending changes.
|
||||
2. Group changes into **logical commits** — each commit should represent one
|
||||
2. **Triage: session vs pre-existing.** Separate changes you made in this
|
||||
session from changes that were already dirty when the session started.
|
||||
Check the conversation history — if you didn't touch a file, it's
|
||||
pre-existing. After grouping your own commits, surface any remaining
|
||||
modified or untracked files to the user:
|
||||
|
||||
> These files are also modified/untracked but weren't part of this
|
||||
> session's work:
|
||||
> - `path/to/file` — <brief description of the diff>
|
||||
> - `path/to/other` — <untracked, N files>
|
||||
>
|
||||
> Should any of these be included in a commit?
|
||||
|
||||
Wait for the user's answer before staging them. Never silently skip
|
||||
or silently include pre-existing changes.
|
||||
3. Group changes into **logical commits** — each commit should represent one
|
||||
coherent change. Common groupings:
|
||||
- A bug fix (all files touched to fix one issue)
|
||||
- A new feature or system (e.g., new trait files + localization + events)
|
||||
- Refactoring / cleanup (encoding fixes, comment fixes, renaming)
|
||||
- Config / meta changes (CLAUDE.md, .claude/ skills, mod descriptor)
|
||||
- Data corrections (province remapping, missing fields)
|
||||
3. For each logical group, stage only the relevant files and commit with a
|
||||
4. For each logical group, stage only the relevant files and commit with a
|
||||
properly formatted message.
|
||||
4. After all commits, update CHANGELOG.md.
|
||||
5. After all commits, update CHANGELOG.md.
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
@@ -73,6 +88,24 @@ Use the project subsystem as scope. Examples:
|
||||
- Never combine unrelated changes (e.g., don't mix a crash fix with new features).
|
||||
- When in doubt, prefer more smaller commits over fewer large ones.
|
||||
|
||||
### Clerk safety valve (`Clerk-Skip:` trailer)
|
||||
|
||||
The pre-push clerk reviews each commit for D-record consistency. For a pure bulk
|
||||
content commit — e.g. shipping thousands of generated planetary description files
|
||||
— that review is moot and just burns agents on noise. Add a `Clerk-Skip:` **trailer
|
||||
line** to the commit message and `tooling/clerk-review` auto-approves that commit
|
||||
without spawning an agent. Use it only for content/data dumps, never for commits
|
||||
that touch `decisions/`, code, or ticket-bearing work.
|
||||
|
||||
It must be a trailer (a line starting with `Clerk-Skip:`), not inline prose — that
|
||||
way a commit that merely *mentions* the token in its subject or body isn't skipped.
|
||||
|
||||
```
|
||||
data(content): generate 5000 planetary description files
|
||||
|
||||
Clerk-Skip: bulk generated content, no D-record surface
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
---
|
||||
name: pr-push
|
||||
name: pr-process
|
||||
description: >
|
||||
Push commits and create or update a pull request. Use when the user says
|
||||
"push pr", "push and create pr", "update pr", "create a pr", "open a pr",
|
||||
or invokes /pr-push. NOT triggered by plain "push" (that's just git push).
|
||||
Pushes the current branch, creates a PR if none exists, or confirms the
|
||||
existing PR was updated. NEVER merges the PR into main — this skill only
|
||||
pushes to the branch and manages the PR lifecycle.
|
||||
Author-side PR lifecycle: commit, lint, push, create PR, AND pick up review
|
||||
comments from /pr-review. Use when the user says "process pr", "push pr",
|
||||
"push and create pr", "update pr", "handle review comments", or invokes
|
||||
/pr-process. Runs from the worktree. The counterpart to /pr-review which
|
||||
runs from main.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion, Skill
|
||||
---
|
||||
|
||||
# Push PR Skill
|
||||
# Process PR Skill
|
||||
|
||||
Push commits to remote and create or update a PR. Operates exclusively on the
|
||||
current branch — never touches main.
|
||||
@@ -26,13 +25,27 @@ 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
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
If on `main`, stop: "You're on main. Switch to a team branch first."
|
||||
If on `main`, stop: "You're on main. Switch to a topic branch first."
|
||||
|
||||
### 1a. Orphan process check (MANDATORY)
|
||||
|
||||
@@ -201,6 +214,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
|
||||
@@ -260,13 +345,34 @@ Report which tickets were moved to review. Skip tickets that are
|
||||
already `done`, `review`, `cancelled`, or `backlog` (only transition
|
||||
`in_progress` → `review`).
|
||||
|
||||
### 9. Next steps
|
||||
### 9. Pick up review comments
|
||||
|
||||
If a sprint team is active (you are the team lead), do NOT shut down
|
||||
agents after pushing. The team should remain alive for PR review and
|
||||
potential comment fixes.
|
||||
Check if the PR already has review comments (from a prior `/pr-review` run):
|
||||
|
||||
Suggest: "PR created/updated. Run `/pr-review` to review before merge."
|
||||
```bash
|
||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||
```
|
||||
|
||||
If comments exist and contain a review verdict (look for "CHANGES REQUESTED" or
|
||||
"REQUEST_CHANGES" or a structured review table):
|
||||
|
||||
1. Parse each file-specific issue from the review comment
|
||||
2. Present each issue to the user (or working agents)
|
||||
3. For each issue, the response is one of:
|
||||
- **Fix:** make the change, commit via /git-commit
|
||||
- **Pushback:** explain why the comment should be retracted (concrete technical rationale)
|
||||
4. After addressing all comments, re-run lint + smoke checks (steps 1b, 1c)
|
||||
5. Push updated commits (step 5)
|
||||
6. Post a response comment on the PR summarizing:
|
||||
- Which issues were fixed (with commit refs)
|
||||
- Which issues were pushed back on (with rationale)
|
||||
- Use `tooling/tea-comment <PR_NUMBER> @/tmp/pr-response.md`
|
||||
|
||||
If no review comments exist, or the review is APPROVED, skip this step.
|
||||
|
||||
### 10. Next steps
|
||||
|
||||
Suggest: "PR processed. Run `/pr-review` from main to review, or `/whats-next` for the next batch."
|
||||
|
||||
## Arguments
|
||||
|
||||
@@ -104,17 +104,28 @@ Then determine the branch:
|
||||
|
||||
### 2. Determine reviewer team
|
||||
|
||||
Map the branch name to a reviewer set. Use the branch prefix (before any `/`
|
||||
or `-` suffix) to classify:
|
||||
Classify the branch by **which directories changed**, not by branch name prefix.
|
||||
Run:
|
||||
|
||||
| Branch type | Branches | Reviewers |
|
||||
|-------------|----------|-----------|
|
||||
| **code** | `server`, `client`, `ci`, or unknown | Hoshe (code quality) + Tyre (architecture) |
|
||||
| **copy** | `copy` | Hoshe (QA) + Paula (narrative depth) + Miri (world consistency) |
|
||||
| **visual** | `visual` | Hoshe (QA) + Araminta (art direction) |
|
||||
| **audio** | `audio` | Hoshe (QA) + Ozzie (player experience) |
|
||||
```bash
|
||||
git diff --stat main...<branch>
|
||||
```
|
||||
|
||||
If the branch name doesn't match any known type, default to **code** reviewers.
|
||||
Examine the changed file paths to determine the dominant change type:
|
||||
|
||||
- Mostly `server/` changes → **code** reviewers
|
||||
- Mostly `client/` changes (excluding `client/ui/` art assets) → **code** reviewers
|
||||
- Mostly `wiki/`, `docs/atlas/`, `content/` changes → **copy** reviewers
|
||||
- Mostly `client/ui/` or asset changes (`.tres`, `.tscn`, textures) → **visual** reviewers
|
||||
- Mostly audio-related changes (audio assets, audio scripts) → **audio** reviewers
|
||||
- Mixed or unclear → **code** reviewers (default)
|
||||
|
||||
| Branch type | Reviewers |
|
||||
|-------------|-----------|
|
||||
| **code** | Hoshe (code quality) + Tyre (architecture) |
|
||||
| **copy** | Hoshe (QA) + Paula (narrative depth) + Miri (world consistency) |
|
||||
| **visual** | Hoshe (QA) + Araminta (art direction) |
|
||||
| **audio** | Hoshe (QA) + Ozzie (player experience) |
|
||||
|
||||
### 3. Generate the diff and read source files
|
||||
|
||||
@@ -138,24 +149,54 @@ 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>
|
||||
# Find worktree for this branch (if one exists)
|
||||
WORKTREE=$(git worktree list --porcelain | grep -B2 "branch refs/heads/<branch>" | grep "worktree " | sed 's/worktree //')
|
||||
|
||||
# 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
|
||||
worktree has been torn down or you're reviewing a branch without a
|
||||
worktree), 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 +276,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
|
||||
@@ -260,26 +312,20 @@ tea pr close --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
Gitea does **not** auto-close PRs when you push a local merge — always close
|
||||
manually with `tea pr close` after pushing.
|
||||
|
||||
### 8. Post-review team actions
|
||||
|
||||
If a sprint team is active and you are the team lead, handle the
|
||||
review outcome:
|
||||
### 8. Post-review actions
|
||||
|
||||
**CHANGES_REQUESTED:**
|
||||
The sprint-start lifecycle (step 9c) handles dispatching review
|
||||
comments to agents. After presenting results, remind the lead:
|
||||
"Review requested changes. Create tasks from each issue and dispatch
|
||||
to idle agents, then re-push and re-review."
|
||||
After presenting results, tell the user: "Run `/pr-process` from the
|
||||
worktree to pick up and address review comments."
|
||||
|
||||
The team may **push back** on specific comments. When a team agent
|
||||
The author may **push back** on specific comments. When the author
|
||||
disagrees with a reviewer comment, the process is:
|
||||
|
||||
1. The team agent explains why the comment should be retracted — with
|
||||
1. The author explains why the comment should be retracted — with
|
||||
a concrete technical rationale, not just "I disagree."
|
||||
2. The team lead (you) evaluates the pushback. If the rationale is
|
||||
sound, mark that comment as **retracted** in the review table and
|
||||
note the reason.
|
||||
3. If the team lead is unsure, escalate to the user for a ruling.
|
||||
2. Evaluate the pushback. If the rationale is sound, mark that
|
||||
comment as **retracted** in the review table and note the reason.
|
||||
3. If unsure, escalate to the user for a ruling.
|
||||
4. Retracted comments do NOT need to be fixed. The re-review should
|
||||
note which comments were retracted and why.
|
||||
|
||||
@@ -288,9 +334,8 @@ that every comment is taken seriously. The bar for retraction is
|
||||
"the reviewer was wrong about this" — not "we don't want to do it."
|
||||
|
||||
**APPROVED:**
|
||||
The sprint-start lifecycle (step 9c) handles shutdown. After
|
||||
presenting results, remind the lead: "Review approved. Proceed with
|
||||
team shutdown per sprint-start step 9c."
|
||||
After presenting results, tell the user: "Review approved. Proceed
|
||||
to merge from main (section 7)."
|
||||
|
||||
## Tips from practice
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
---
|
||||
name: sprint-plan
|
||||
description: >
|
||||
Plan the next sprint and generate team briefing files. Use when the user says
|
||||
"plan sprint", "prep sprint briefing", "plan next sprint", "sprint planning",
|
||||
or invokes /sprint-plan. Gathers current sprint status, scans the backlog,
|
||||
proposes ticket selection, and writes briefing files per team to
|
||||
docs/sprints/sprint-N/.
|
||||
user-invocable: true
|
||||
allowed-tools: Task, Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Plan Sprint
|
||||
|
||||
**Delegate this entire skill to SI** (Project Manager agent, `subagent_type: si`).
|
||||
|
||||
When this skill is invoked, spawn SI using the Task tool:
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "si",
|
||||
prompt: "Run /sprint-plan for Sprint N. Read the skill at
|
||||
.claude/skills/sprint-plan/SKILL.md for the full workflow,
|
||||
then execute it. Use the arguments provided: {args}",
|
||||
description: "Plan sprint N"
|
||||
)
|
||||
```
|
||||
|
||||
Pass through any arguments the user provided (e.g. sprint number).
|
||||
Present SI's output to the user when done. Do NOT run the workflow yourself.
|
||||
|
||||
---
|
||||
|
||||
The remainder of this file is SI's reference for executing the workflow.
|
||||
|
||||
Generate sprint briefing files for each active team (e.g. `server.md`,
|
||||
`client.md`, `copy.md`, `joint.md`) for the next sprint based on current
|
||||
project state. Only generate briefings for teams that have tickets in the sprint.
|
||||
|
||||
## Teams and Default Agents
|
||||
|
||||
| Team | Branch | Default Agents | Focus |
|
||||
|------|--------|----------------|-------|
|
||||
| `server` | `server` | Dudley (dev), Tyre (arch), Hoshe (QA) | Rust/bevy_ecs simulation, ECS systems, knowledge graph, perception |
|
||||
| `client` | `client` | Stig (dev), Tyre (arch), Hoshe (QA) | Godot 4 client, rendering, UI, HUD, input handling |
|
||||
| `copy` | `copy` | Mellanie (author), Paula (narrative), Gestalt (systems) | Dialogue, monologue, UI text, knowledge vocabulary, lore |
|
||||
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
|
||||
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
|
||||
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
|
||||
| `planning` | `planning` | Purpose-assembled (see below) | Design discussions, decision resolution, workshop-style tickets |
|
||||
|
||||
When writing briefings, name the assigned agents in the **Agents** line of each
|
||||
file so the team knows who to spawn.
|
||||
|
||||
### Planning Team Tickets
|
||||
|
||||
Some tickets need **design discussion** before implementation can begin — tagged
|
||||
"NEEDS DESIGN DISCUSSION" or blocking multiple downstream tickets with open
|
||||
questions. These run on the `planning` branch as structured discussions with
|
||||
the user and a purpose-assembled agent panel.
|
||||
|
||||
**When to create a planning ticket:**
|
||||
- Ticket description says "NEEDS DESIGN" or "NEEDS DESIGN DISCUSSION"
|
||||
- Ticket blocks 2+ downstream tickets across different teams
|
||||
- Open Q-NNN items that block sprint candidates
|
||||
- Architectural decisions that need multi-domain input before implementation
|
||||
|
||||
**Planning briefing format** (differs from implementation briefings):
|
||||
- **Agents line**: List agents by domain relevance, not fixed team roster.
|
||||
Pick from: Gestalt (systems), Miri (worldbuilding), Araminta (visual/spatial),
|
||||
Tyre (technical), Paula (narrative), Ozzie (player experience), Gore (themes),
|
||||
Nigel (replayability). Typically 4-6 domain agents, plus Qatux (documenter —
|
||||
records decisions, updates domain files) and SI (project manager — creates
|
||||
follow-up tickets, updates sprint assignments).
|
||||
- **Discussion rounds**: Structure the conversation into 2-3 rounds
|
||||
(inventory → proposals → convergence)
|
||||
- **Context section**: List all existing design docs, decisions, and related
|
||||
tickets that participants must read before the discussion
|
||||
- **Output specification**: What the discussion must produce — typically a
|
||||
D-record in `decisions/`, possibly a design doc in `docs/design/`
|
||||
- **Decision questions**: Specific questions the discussion must answer,
|
||||
not open-ended exploration
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Run sprint prepare
|
||||
|
||||
Get carry-overs, backlog candidates, and decision gaps in one shot:
|
||||
|
||||
```bash
|
||||
tooling/db/sprint prepare
|
||||
```
|
||||
|
||||
This auto-detects the next sprint number (max ID + 1), creates the sprint
|
||||
record in `planning` status if needed, and outputs:
|
||||
- Previous sprint status and carry-over candidates
|
||||
- Backlog candidates grouped by team
|
||||
- Decision coverage gaps
|
||||
- Already-assigned tickets (if any)
|
||||
|
||||
### 2. Deepen the scan
|
||||
|
||||
For critical epics, check their children for granular candidates:
|
||||
|
||||
```bash
|
||||
tooling/db/ticket children <epic_id>
|
||||
```
|
||||
|
||||
Use `tooling/db/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
|
||||
|
||||
### 3. Read existing code state
|
||||
|
||||
Scan what's already built to write accurate "what exists" notes:
|
||||
|
||||
```bash
|
||||
# Server modules
|
||||
ls server/src/ server/src/*/
|
||||
|
||||
# Client scripts
|
||||
ls client/scripts/ client/scripts/*/
|
||||
```
|
||||
|
||||
Read key files that sprint tickets will build on (bridge types, existing
|
||||
renderers, etc.) to reference specific integration points in the briefing.
|
||||
|
||||
### 4. Select tickets — propose to user
|
||||
|
||||
Based on the backlog scan, propose a sprint with:
|
||||
|
||||
- **Sprint theme** — a short name (Sprint 1 was "Run", Sprint 2 was "See")
|
||||
- **Sprint goal** — one sentence shared across all teams
|
||||
- **Server tickets** — stories from server-side epics
|
||||
- **Client tickets** — stories from client-side epics
|
||||
- **Joint tasks** — integration proofs, pre-sprint decisions, schema work
|
||||
|
||||
Present the proposal using AskUserQuestion for the user to approve or adjust.
|
||||
|
||||
Selection heuristics:
|
||||
- Follow dependency chains (don't pick a ticket if its blocker isn't in scope)
|
||||
- Respect D-030 test priority phases (sprint 1-2: infra, sprint 3-4: integration)
|
||||
- Mix carry-overs with new work
|
||||
- Aim for 3-6 tickets per team, with parallel tracks where possible
|
||||
- Check `decisions/questions.md` for open Q-NNN items that block candidates
|
||||
|
||||
### 5. Read relevant decisions
|
||||
|
||||
For the selected tickets, identify which `decisions/*.md` files are relevant.
|
||||
Read them to provide accurate cross-references in the briefing.
|
||||
|
||||
### 6. Write briefing files
|
||||
|
||||
Create `docs/sprints/sprint-N/` and write one file per team.
|
||||
|
||||
Read the template at `references/briefing-template.md` in this skill directory
|
||||
for the exact file structure.
|
||||
|
||||
**Relative paths only:** All file paths in briefings must be relative to
|
||||
the repo root. Example: `server/src/bridge/types.rs`, not absolute paths.
|
||||
Each sprint branch (`sprint-{N}/{team}`) contains the full repo.
|
||||
|
||||
Key requirements per file:
|
||||
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
|
||||
referencing existing Rust modules by path from worktree root (e.g. `server/src/simulation/movement.rs`)
|
||||
- **client.md**: Same structure, notes referencing existing GDScript files by path from worktree root (e.g. `client/scripts/rendering/fog.gd`)
|
||||
- **copy.md**: In-game text tasks — dialogue, UI copy, tooltips, flavor text, lore
|
||||
- **audio.md**: Sound design, music, audio integration tasks
|
||||
- **visual.md**: Art direction, asset creation, visual consistency tasks
|
||||
- **ci.md**: Build pipeline, CI/CD, tooling, infrastructure tasks
|
||||
- **joint.md**: Pre-sprint decisions table, integration tickets, sprint
|
||||
completion proof (concrete observable criteria), test plan alignment
|
||||
|
||||
Only generate briefing files for teams that have tickets assigned in the sprint.
|
||||
Not every sprint will have work for every team.
|
||||
|
||||
### 7. Assign tickets to sprint in DB
|
||||
|
||||
After the user approves, assign all selected tickets. The sprint record
|
||||
was already created by `sprint prepare` in step 1 (status: `planning`).
|
||||
Update it with the theme and goal, then assign tickets:
|
||||
|
||||
```bash
|
||||
# Update the sprint with theme and goal
|
||||
tooling/db/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N"
|
||||
|
||||
# Assign tickets
|
||||
tooling/db/ticket sprint assign <ticket_id> <sprint_id>
|
||||
```
|
||||
|
||||
The sprint stays in `planning` status until explicitly activated via
|
||||
`tooling/db/sprint start`. This prevents starting an unplanned sprint.
|
||||
|
||||
### 8. Commit and push
|
||||
|
||||
Stage the briefing files and any other changes (db backup, closed tickets),
|
||||
then commit and push so worktree branches can pull the planning artifacts:
|
||||
|
||||
```bash
|
||||
git add docs/sprints/sprint-N/
|
||||
make db-backup
|
||||
git add docs/backups/settledreach.db.backup
|
||||
git commit -m "chore(meta): plan Sprint N: Theme"
|
||||
git push
|
||||
```
|
||||
|
||||
### 9. Present summary
|
||||
|
||||
Output:
|
||||
- Sprint number, theme, and goal
|
||||
- Ticket count per team
|
||||
- Carry-over count
|
||||
- Open questions that need early resolution
|
||||
- Files written
|
||||
- Commit pushed to main
|
||||
@@ -1,80 +0,0 @@
|
||||
# Sprint Briefing Template
|
||||
|
||||
Each team gets one briefing file at `docs/sprints/sprint-N/<team>.md`.
|
||||
|
||||
## File structure
|
||||
|
||||
```markdown
|
||||
# Sprint N: <Theme> — <Team> Tasks
|
||||
|
||||
**Goal:** <One-sentence sprint goal, shared across all teams>
|
||||
|
||||
**Branch:** `sprint-{N}/<team>`
|
||||
**Agents:** <Agent names and roles>
|
||||
|
||||
## Carry-over from Sprint N-1
|
||||
|
||||
(Only if there are incomplete tickets from the previous sprint)
|
||||
|
||||
| # | Title | Status | Notes |
|
||||
|---|-------|--------|-------|
|
||||
| #ID | Title | status | Why it carried over |
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #ID | Title | #dependency or — |
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/<domain>.md` — D-NNN (short name), D-NNN (short name)
|
||||
|
||||
## Open Questions to Resolve Early
|
||||
|
||||
(Only if there are Q-NNN items that block sprint tickets)
|
||||
|
||||
- **Q-NNN: Title** — Brief context. Resolve before #ID starts.
|
||||
|
||||
## Notes
|
||||
|
||||
One bullet per ticket with:
|
||||
- What exists already (files, modules, stubs)
|
||||
- What the ticket actually needs to deliver
|
||||
- Integration points with other tickets
|
||||
- Non-obvious gotchas
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#A (name) → #B (name) → #C (name)
|
||||
#D (name) → standalone, parallel track
|
||||
```
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
\```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(<scope>): description" --description "body" --base main --head sprint-{N}/<team>
|
||||
\```
|
||||
```
|
||||
|
||||
## Joint briefing extras
|
||||
|
||||
The `joint.md` file additionally includes:
|
||||
|
||||
- **Pre-Sprint** table: decisions/schema work that must happen before implementation
|
||||
- **Sprint Completion Proof**: concrete observable criteria (what you can see/do when the sprint is done)
|
||||
- **Test plan** alignment with D-030 phases
|
||||
|
||||
## Team assignments
|
||||
|
||||
| Team | Branch | Agents | Scope |
|
||||
|------|--------|--------|-------|
|
||||
| server | `sprint-{N}/server` | Dudley (simulation), Oscar (networking) | server/, Rust/bevy_ecs simulation |
|
||||
| client | `sprint-{N}/client` | Stig (UI), Oscar (networking) | client/, Godot rendering |
|
||||
| copy | `sprint-{N}/copy` | Mellanie, Paula, Miri | wiki/, docs/atlas/, content/ |
|
||||
| joint | both | All implementation agents | Integration, proofs, cross-team schema |
|
||||
| content | (none) | Mellanie, Paula, Miri, Araminta | Content authoring, no code branch |
|
||||
@@ -1,582 +0,0 @@
|
||||
---
|
||||
name: sprint-start
|
||||
description: >
|
||||
Manage the sprint lifecycle from main, or start sprint work on a team
|
||||
branch. Use when the user says "start sprint", "start working on the
|
||||
server/client/copy", "begin sprint", or invokes /sprint-start. On main:
|
||||
assesses sprint state and does the next right thing (close, activate, or
|
||||
guide). On a team branch: merges main, loads the briefing, presents the
|
||||
work plan.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
|
||||
---
|
||||
|
||||
# Start Sprint Skill
|
||||
|
||||
Prepare a team branch for sprint work: sync with main, load the sprint
|
||||
briefing, and present actionable next steps.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine the team
|
||||
|
||||
The current branch IS the team. Read it with:
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
Sprint branches follow the pattern `sprint-{N}/{team}` (e.g. `sprint-31/server`).
|
||||
Valid team names: `server`, `client`, `copy`, `audio`, `visual`, `ci`, `planning`.
|
||||
|
||||
If on `main`, follow the **Main branch workflow** below instead of
|
||||
the team branch workflow (steps 2–8).
|
||||
|
||||
---
|
||||
|
||||
## Main branch workflow (sprint lifecycle management)
|
||||
|
||||
When `/sprint-start` is run on `main`, assess the current sprint state
|
||||
and do the next right thing. Query the database to determine the state:
|
||||
|
||||
```bash
|
||||
tooling/db/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
|
||||
```
|
||||
|
||||
Then follow the **first matching case**:
|
||||
|
||||
### Case A: An active sprint exists
|
||||
|
||||
First, check whether the sprint's work is actually done:
|
||||
|
||||
```bash
|
||||
tooling/db/sprint status
|
||||
```
|
||||
|
||||
This shows ticket counts by status (done, in_progress, backlog).
|
||||
|
||||
Also check for open PRs that may contain completed work waiting for
|
||||
review or merge:
|
||||
|
||||
```bash
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
Report the full picture to the user:
|
||||
- Ticket progress (done / in_progress / backlog counts)
|
||||
- Open PRs (if any — these represent work that's done but not merged)
|
||||
|
||||
**If tickets remain unfinished** (in_progress or backlog) **or open PRs
|
||||
exist**, do NOT close the sprint. Instead, report the current progress
|
||||
and ask the user what they want to do:
|
||||
|
||||
- **Continue working** — switch to a team branch and run `/sprint-start`
|
||||
there to resume work
|
||||
- **Review & merge PRs first** — (only if open PRs exist) merge pending
|
||||
work before deciding whether to close
|
||||
- **Close anyway** — proceed with the close workflow below (carries over
|
||||
unfinished tickets)
|
||||
|
||||
Use `AskUserQuestion` to confirm. Do not proceed to A1 unless the user
|
||||
explicitly chooses to close.
|
||||
|
||||
#### A1. Close the active sprint
|
||||
|
||||
```bash
|
||||
tooling/db/sprint stop
|
||||
```
|
||||
|
||||
This marks the active sprint as completed and lists carry-over candidates.
|
||||
Note the sprint number (N) from the output.
|
||||
|
||||
#### A1b. Sprint retrospective and review
|
||||
|
||||
Before bumping the version, run a brief retro. Present the following to
|
||||
the user:
|
||||
|
||||
1. **What shipped** — list completed tickets with one-line summaries
|
||||
2. **What didn't ship** — carry-overs and why (blocked, cut, deprioritized)
|
||||
3. **What we learned** — open questions raised during the sprint (new Q-NNN
|
||||
items), review findings that surfaced design gaps, and any assumptions
|
||||
that turned out to be wrong
|
||||
4. **Process notes** — what worked well, what was friction (e.g. dependency
|
||||
chains that blocked teams, specs that were over/under-specified,
|
||||
review cycles that caught real issues vs busywork)
|
||||
|
||||
5. **Process improvements** — this is the most important section. Do NOT
|
||||
skip it. Look for:
|
||||
- Dependency chains that blocked teams — could the sprint have been
|
||||
structured differently to avoid the bottleneck?
|
||||
- Specs that were over-specified (wasted planning) or under-specified
|
||||
(wasted iteration) — what's the right level of detail for this
|
||||
project's current stage?
|
||||
- Review cycles — did they catch real issues or create busywork?
|
||||
- Agent coordination — were agents stuck, duplicating work, or idle?
|
||||
- **Dig into the deeper why.** Don't stop at "the dependency chain
|
||||
blocked the copy team." Ask: why was there a dependency chain? Was
|
||||
the sprint structured wrong, or was the work inherently sequential?
|
||||
Could Phase 0 have been done pre-sprint? Should we change how we
|
||||
plan sprints going forward?
|
||||
- If something went rough, understand the root cause — not just what
|
||||
happened, but why the process allowed it to happen.
|
||||
- If a concrete process change follows naturally, propose it. But do
|
||||
NOT force improvements. If nothing was broken, say so and move on.
|
||||
Unnecessary process changes are worse than no changes.
|
||||
|
||||
Keep each section concise — a few bullet points, not a document. The
|
||||
retro is a conversation checkpoint, not a report. Use `AskUserQuestion`
|
||||
to let the user add their own observations and push back before proceeding.
|
||||
|
||||
If the user raises items that should be tracked, create Q-NNN entries
|
||||
or backlog tickets on the spot. If process changes are agreed, update
|
||||
the relevant skill files or CLAUDE.md immediately — don't defer them.
|
||||
|
||||
#### A1c. Clean up sprint worktrees
|
||||
|
||||
Remove ephemeral worktrees for the closed sprint. Run the teardown script:
|
||||
|
||||
```bash
|
||||
.claude/skills/sprint-start/scripts/sprint-teardown.sh {N}
|
||||
```
|
||||
|
||||
This removes all worktrees under `.sprint/sprint-{N}/` and prunes git
|
||||
metadata. Safe to skip if the sprint didn't use ephemeral worktrees
|
||||
(e.g. legacy persistent worktree setup).
|
||||
|
||||
#### A2. Bump the version
|
||||
|
||||
The project version scheme is `v0.1.{sprint_number}`. After closing
|
||||
sprint N, the version is `v0.1.N`.
|
||||
|
||||
Update `project.yaml`:
|
||||
- Set the `version` field to `0.1.N` (this is the source of truth).
|
||||
|
||||
Update `server/Cargo.toml`:
|
||||
- Set `version = "0.1.N"` in `[package]`.
|
||||
|
||||
Update `CHANGELOG.md`:
|
||||
- Move all entries under `## [Unreleased]` into a new section
|
||||
`## [v0.1.N] — YYYY-MM-DD` (using today's date).
|
||||
- Leave `## [Unreleased]` as an empty section above the new version.
|
||||
- Keep the existing sub-headings (Added, Fixed, Changed, Removed) —
|
||||
only move entries that have content.
|
||||
|
||||
#### A3. Commit the release
|
||||
|
||||
Stage and commit `project.yaml`, `server/Cargo.toml`, and `CHANGELOG.md`:
|
||||
```
|
||||
chore(meta): release v0.1.N
|
||||
```
|
||||
|
||||
#### A4. Tag the release
|
||||
|
||||
```bash
|
||||
git tag v0.1.N
|
||||
```
|
||||
|
||||
#### A5. Push
|
||||
|
||||
```bash
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
#### A6. Check for a planned sprint
|
||||
|
||||
After closing, re-query the database. If a sprint in `planning` status
|
||||
exists, continue to **Case B**. Otherwise, report the close and suggest
|
||||
running `/sprint-plan`.
|
||||
|
||||
---
|
||||
|
||||
### Case B: No active sprint, but a planned sprint exists
|
||||
|
||||
A sprint is ready to activate. Verify it looks complete:
|
||||
|
||||
1. Check that briefing files exist at `docs/sprints/sprint-N/`:
|
||||
```bash
|
||||
ls docs/sprints/sprint-N/
|
||||
```
|
||||
2. Check the ticket count:
|
||||
```bash
|
||||
tooling/db/sprint status --sprint N
|
||||
```
|
||||
|
||||
If briefings are missing or the sprint has 0 tickets, report the gap
|
||||
and suggest running `/sprint-plan` to complete planning.
|
||||
|
||||
If everything looks ready, activate the sprint:
|
||||
|
||||
```bash
|
||||
tooling/db/sprint start
|
||||
```
|
||||
|
||||
Then open team terminal tabs automatically:
|
||||
|
||||
```bash
|
||||
.claude/skills/sprint-start/scripts/start-sprint.sh
|
||||
```
|
||||
|
||||
This creates ephemeral worktrees under `.sprint/sprint-{N}/{team}/` for
|
||||
each team with open tickets, and opens Ptyxis windows with tmux + Claude
|
||||
auto-starting in each tab. The user will have one tab per active team.
|
||||
|
||||
Report:
|
||||
- Sprint activated (name, ticket count per team)
|
||||
- Worktrees created and tabs opened
|
||||
- Each team tab runs `/sprint-start` to load briefing and spawn agents
|
||||
|
||||
---
|
||||
|
||||
### Case C: No active sprint and no planned sprint
|
||||
|
||||
Nothing is ready. Report the state and suggest running `/sprint-plan`
|
||||
to plan the next sprint.
|
||||
|
||||
---
|
||||
|
||||
## Team branch workflow
|
||||
|
||||
### 2. Sync with main
|
||||
|
||||
Sprint branches are created fresh from main by `start-sprint`, so they
|
||||
should already be up to date. If main has moved since branch creation:
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
git merge origin/main --no-edit
|
||||
```
|
||||
|
||||
If the merge has conflicts, report them and stop — do not force-resolve.
|
||||
|
||||
### 3. Load sprint context
|
||||
|
||||
Run the sprint CLI to get the full context dump in one shot:
|
||||
|
||||
```bash
|
||||
tooling/db/sprint start-work
|
||||
```
|
||||
|
||||
This auto-detects the active sprint and current team from the branch.
|
||||
It outputs: sprint metadata, briefing paths, decision refs, actionable
|
||||
tickets, blocked tickets, and done tickets.
|
||||
|
||||
If no active sprint is found, report that and stop.
|
||||
|
||||
### 4. Read the sprint briefing
|
||||
|
||||
Read the briefing file(s) listed in the `start-work` output
|
||||
(e.g. `docs/sprints/sprint-6/server.md` and `joint.md`).
|
||||
If no matching briefing exists for the team, suggest running
|
||||
`/sprint-plan` to generate one.
|
||||
|
||||
### 5. Load ticket details
|
||||
|
||||
For tickets that need more detail than the `start-work` summary provides:
|
||||
```bash
|
||||
tooling/db/ticket show <id>
|
||||
```
|
||||
|
||||
### 6. Read key decisions
|
||||
|
||||
Read the decision files referenced in the sprint briefing so the agent has
|
||||
full architectural context before starting work.
|
||||
|
||||
### 7. Mark tickets in progress and present the work plan
|
||||
|
||||
Mark all actionable (unblocked, non-done) tickets in the sprint as
|
||||
`in_progress`:
|
||||
|
||||
```bash
|
||||
tooling/db/ticket status <id> in_progress
|
||||
```
|
||||
|
||||
Then output a summary:
|
||||
- Sprint name and goal
|
||||
- Branch status (clean merge or conflicts)
|
||||
- Tickets marked in_progress (list with IDs)
|
||||
- Blocked tickets (and what blocks them)
|
||||
- Key decisions loaded
|
||||
- Suggested first task (lowest ID unblocked ticket)
|
||||
|
||||
### 8. Confirm and spawn the team
|
||||
|
||||
Before spawning agents, use `AskUserQuestion` to confirm the work plan and
|
||||
agent lineup with the user. If declined, stop.
|
||||
|
||||
Once confirmed:
|
||||
|
||||
#### 8a. Parse agents from the briefing
|
||||
|
||||
Extract agent names from the `**Agents:**` line. Format:
|
||||
```
|
||||
**Agents:** Name (role), Name (role), ...
|
||||
```
|
||||
|
||||
Map each name to its `subagent_type` (lowercase):
|
||||
- "Dudley (simulation)" → `dudley`
|
||||
- "Stig (UI)" → `stig`
|
||||
- "Tyre (architecture)" → `tyre`
|
||||
- "Hoshe (QA)" → `hoshe`
|
||||
- "Mellanie (author)" → `mellanie`
|
||||
- etc. (see `.claude/agents/` for full roster)
|
||||
|
||||
#### 8b. Create the team
|
||||
|
||||
```
|
||||
TeamCreate(team_name: "sprint-{N}-{team}")
|
||||
```
|
||||
|
||||
This makes you the team lead.
|
||||
|
||||
#### 8c. Create tasks from tickets
|
||||
|
||||
For each ticket in the briefing, create a task:
|
||||
|
||||
```
|
||||
TaskCreate(
|
||||
subject: "#{id}: {title}",
|
||||
description: "Full ticket details from step 5, plus briefing notes
|
||||
and integration points for this ticket.",
|
||||
activeForm: "Working on #{id}: {short_title}"
|
||||
)
|
||||
```
|
||||
|
||||
After creating all tasks, mirror the dependency chain from the briefing
|
||||
using `TaskUpdate` with `addBlockedBy`.
|
||||
|
||||
#### 8d. Spawn agents
|
||||
|
||||
For each agent from the `**Agents:**` line, spawn a teammate in the
|
||||
background. Spawn all agents in parallel (one message, multiple Task calls):
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "{name_lowercase}",
|
||||
team_name: "sprint-{N}-{team}",
|
||||
name: "{name_lowercase}",
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `sprint-{N}/{team}`
|
||||
|
||||
RULES (NON-NEGOTIABLE):
|
||||
|
||||
0. TEAM SCOPE: Your team is `{team}` on branch `sprint-{N}/{team}`.
|
||||
Stay within your team's file scope (server → server/, client → client/, copy → wiki/ + docs/atlas/ + content/).
|
||||
You may read (but not modify): docs/, decisions/, wiki/, .claude/
|
||||
Do NOT modify files belonging to other teams.
|
||||
|
||||
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead. No exceptions.
|
||||
|
||||
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
tooling/db/ticket show 528
|
||||
tooling/db/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
|
||||
3. READ BEFORE WRITE: Before modifying ANY file, Read it first.
|
||||
Before creating a new file, Glob for similar files to learn
|
||||
the existing patterns (naming, structure, imports). Follow
|
||||
the conventions you find — do not invent new ones.
|
||||
|
||||
4. VERIFY AFTER WRITE: After implementing a change, grep for
|
||||
all references to functions/properties/classes you modified
|
||||
or removed. If you renamed, moved, or deleted something,
|
||||
update EVERY call site. Missing a call site breaks tests
|
||||
and blocks the team.
|
||||
|
||||
5. NO PARTIAL WORK: Do not mark a task completed unless ALL
|
||||
parts of the ticket are implemented. If the ticket says
|
||||
'deliver A, B, and C', all three must exist and work. If
|
||||
you cannot complete part of a task, message the team lead
|
||||
explaining what is blocked and what remains — do NOT mark
|
||||
it completed.
|
||||
|
||||
6. MESSAGE WHEN BLOCKED: If you hit a problem you cannot solve
|
||||
in 3 attempts, stop and message the team lead immediately.
|
||||
Do not silently skip work or leave stubs. Do not move to
|
||||
the next task while the current one is incomplete.
|
||||
|
||||
7. BACKWARD COMPATIBILITY: When extracting, moving, or
|
||||
refactoring code, ensure all existing consumers still work.
|
||||
Add proxy methods/properties if needed. Grep for the old
|
||||
name to find every call site.
|
||||
|
||||
WORKFLOW:
|
||||
|
||||
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
||||
2. Read the decision files referenced in the briefing.
|
||||
3. Check TaskList for available work.
|
||||
4. Claim an unblocked task (TaskUpdate with owner: your name),
|
||||
mark it in_progress, and implement it.
|
||||
5. Before marking done, verify:
|
||||
- All deliverables from the ticket exist (not just some)
|
||||
- No broken references (grep for changed names/signatures)
|
||||
- New files follow existing naming and directory conventions
|
||||
- Modified files still parse (no syntax errors)
|
||||
6. Mark the task completed and check TaskList for the next
|
||||
available task.
|
||||
7. If no tasks remain, message the team lead. Do NOT shut down
|
||||
on your own.
|
||||
|
||||
Use `tooling/db/ticket show <id>` for full ticket specs.",
|
||||
description: "Sprint {N} {team}: {name}",
|
||||
run_in_background: true
|
||||
)
|
||||
```
|
||||
|
||||
#### 8d-planning. Planning team variant — proposers vs executors
|
||||
|
||||
When the team is `planning`, agents work in discussion mode, not
|
||||
implementation mode. The briefing typically defines discussion rounds
|
||||
(propose → review → execute). Most agents are **proposers** — they
|
||||
analyze and recommend. Only designated agents **execute** (write to
|
||||
files, modify DB).
|
||||
|
||||
**Classify agents from the briefing:**
|
||||
- **Executor agents:** SI (project manager), Qatux (documenter).
|
||||
These agents wait for consensus before writing anything.
|
||||
- **Proposer agents:** Everyone else (Gestalt, Tyre, Paula, Ozzie,
|
||||
etc.). These agents analyze, propose, and discuss — they do NOT
|
||||
write decision files, modify the DB, or create/delete tickets.
|
||||
|
||||
**Add this block to proposer agent prompts** (replaces rules 3–5
|
||||
and the WORKFLOW section from the standard template):
|
||||
|
||||
```
|
||||
PLANNING TEAM RULES (replace standard rules 3-7 and WORKFLOW):
|
||||
|
||||
3. PROPOSE ONLY: You are a discussion participant. Your job is
|
||||
to ANALYZE tickets, PROPOSE team assignments, and RECOMMEND
|
||||
splits. You do NOT:
|
||||
- Write or modify decision files (decisions/*.md)
|
||||
- Run DB update/insert/delete commands
|
||||
- Create, delete, or modify tickets
|
||||
- Mark tasks as completed
|
||||
Those actions belong to executor agents (SI, Qatux) after
|
||||
the team lead confirms consensus.
|
||||
|
||||
4. OUTPUT FORMAT: Your first message to the team lead should
|
||||
be your full analysis. Structure it as:
|
||||
- Your position on open questions (with rationale)
|
||||
- Proposed team assignments (table format)
|
||||
- Split candidates (if any)
|
||||
- Sprint readiness assessment
|
||||
Do NOT claim or work tasks. The team lead coordinates.
|
||||
|
||||
5. WAIT FOR CONSENSUS: Do not treat your own proposal as
|
||||
decided. Other agents may disagree. The team lead calls
|
||||
consensus and directs executors to implement it.
|
||||
```
|
||||
|
||||
**Add this block to executor agent prompts** (appended after the
|
||||
standard rules):
|
||||
|
||||
```
|
||||
PLANNING TEAM RULES (additional):
|
||||
|
||||
8. WAIT FOR CONSENSUS: Do NOT execute DB changes, write
|
||||
decision files, or create tickets until the team lead
|
||||
explicitly tells you to. Your first message should confirm
|
||||
you are ready and describe your execution plan. Then wait.
|
||||
|
||||
9. EXECUTE EXACTLY WHAT IS DIRECTED: When the team lead sends
|
||||
you a list of changes, execute them precisely. Do not add
|
||||
extra changes, reinterpret the instructions, or fill gaps
|
||||
with your own judgment. If something is ambiguous, ask
|
||||
before executing.
|
||||
```
|
||||
|
||||
#### 8e. Report
|
||||
|
||||
Output to the user:
|
||||
- Team name: `sprint-{N}-{team}`
|
||||
- Agents spawned (names and roles)
|
||||
- Tasks created (count actionable vs blocked)
|
||||
- How to interact: `SendMessage` to talk to agents, `TaskList` to
|
||||
check progress
|
||||
|
||||
You are now the team lead. Agents work autonomously — monitor via
|
||||
`TaskList`, communicate via `SendMessage`, and handle blockers as
|
||||
they arise.
|
||||
|
||||
**When all tasks complete:** Do NOT shut down agents. The team stays
|
||||
alive through PR review AND merge. Follow step 9 (post-work lifecycle).
|
||||
|
||||
### 9. Post-work lifecycle
|
||||
|
||||
When all tasks are complete (TaskList shows all completed):
|
||||
|
||||
#### 9a. Commit and push
|
||||
|
||||
Run `/git-commit` to commit all changes, then `/pr-push` to create or
|
||||
update the PR. Do NOT shut down agents — the team stays alive through
|
||||
review and merge.
|
||||
|
||||
#### 9b. Wait for review
|
||||
|
||||
Do NOT run `/pr-review` from the team window — PR reviews run from
|
||||
the `main` branch (a separate window/session). The team window stays
|
||||
on its sprint branch.
|
||||
|
||||
After pushing and creating the PR, report the PR number to the user
|
||||
and stop. Wait for review feedback to arrive (the user or the main
|
||||
session will relay it, or it will appear as Gitea PR comments).
|
||||
|
||||
#### 9c. Handle review outcome
|
||||
|
||||
When review feedback arrives (from the user, main session, or PR
|
||||
comments):
|
||||
|
||||
**If CHANGES_REQUESTED:**
|
||||
|
||||
1. Parse the review comment table (from the Gitea PR comment or the
|
||||
review output). Extract each issue with:
|
||||
- File path and approximate line
|
||||
- Description
|
||||
|
||||
2. Create a task per issue:
|
||||
```
|
||||
TaskCreate(
|
||||
subject: "Review: {short description}",
|
||||
description: "{full issue description from review table, including
|
||||
file path and reviewer name}",
|
||||
activeForm: "Fixing review comment: {short description}"
|
||||
)
|
||||
```
|
||||
Every comment is actionable — there is no "suggestion" tier to skip (per pr-review policy).
|
||||
|
||||
3. Dispatch to idle agents: send each a message via SendMessage telling
|
||||
them to check TaskList for new review-fix tasks. Agents claim and
|
||||
work tasks as usual.
|
||||
|
||||
4. After all review-fix tasks are complete, re-run `/git-commit` then
|
||||
`/pr-push` to update the PR. Then re-run `/pr-review`.
|
||||
|
||||
5. Repeat this loop until review returns APPROVED.
|
||||
|
||||
**If APPROVED (but not yet merged):**
|
||||
|
||||
Do NOT shut down. Approval alone is not terminal — reviewers can leave
|
||||
follow-up comments, the PR can be re-reviewed, or merge conflicts can
|
||||
surface. Keep the team alive and idle until the PR is merged into main.
|
||||
|
||||
1. Report to the user: "Sprint {N} {team} PR #{X} approved. Awaiting
|
||||
merge. Team remains alive."
|
||||
2. Agents stay idle. Do not reassign them to unrelated work.
|
||||
3. Periodically check merge state (or wait for the user to confirm the
|
||||
merge). The `main` session handles the merge itself.
|
||||
4. If new review comments arrive between approval and merge, treat it
|
||||
as CHANGES_REQUESTED and re-enter the fix loop.
|
||||
5. Once the PR is merged, proceed to 9d.
|
||||
|
||||
#### 9d. Handle merge completion
|
||||
|
||||
When the PR is confirmed merged into main (user confirmation, Gitea
|
||||
state change, or the `main` session reports the merge):
|
||||
|
||||
1. Send `shutdown_request` to all sprint agents.
|
||||
2. Wait for all `shutdown_response` confirmations.
|
||||
3. Call `TeamDelete` to clean up.
|
||||
4. Report: "Sprint {N} {team} complete. PR #{X} merged into main. Team
|
||||
shut down."
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Clean up ephemeral worktrees for a closed sprint.
|
||||
# Usage: sprint-teardown.sh <sprint-number>
|
||||
#
|
||||
# Removes all worktrees under .sprint/sprint-{N}/ and prunes git metadata.
|
||||
# Safe to run multiple times — skips already-removed worktrees.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
SPRINT_BASE="$(dirname "$REPO_ROOT")/.sprint"
|
||||
SPRINT=${1:?Usage: sprint-teardown.sh <sprint-number>}
|
||||
|
||||
SPRINT_DIR="$SPRINT_BASE/sprint-${SPRINT}"
|
||||
|
||||
if [ ! -d "$SPRINT_DIR" ]; then
|
||||
echo "No worktrees found for sprint-${SPRINT} (directory $SPRINT_DIR does not exist)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Cleaning sprint-${SPRINT} worktrees..."
|
||||
|
||||
for wt in "$SPRINT_DIR"/*/; do
|
||||
[ -d "$wt" ] || continue
|
||||
team="$(basename "$wt")"
|
||||
echo " Removing: $team"
|
||||
git -C "$REPO_ROOT" worktree remove "$wt" --force 2>/dev/null || echo " (already removed or dirty)"
|
||||
done
|
||||
|
||||
rmdir "$SPRINT_DIR" 2>/dev/null || true
|
||||
git -C "$REPO_ROOT" worktree prune
|
||||
|
||||
echo "Done. Sprint-${SPRINT} worktrees cleaned."
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Opens Ptyxis tabs for the active sprint teams in the CURRENT window.
|
||||
# Auto-starts Claude Code in each tab via tmux.
|
||||
#
|
||||
# Usage: start-sprint.sh [sprint-number]
|
||||
# If omitted, auto-detects the active sprint from the database.
|
||||
#
|
||||
# Assumes the caller is already on main in the current terminal.
|
||||
# Adds one tab per active team — no duplicate main tab.
|
||||
#
|
||||
# Worktrees are created under .sprint/ (ephemeral, cleaned after sprint close).
|
||||
# Can be called from any directory — resolves paths from the script location.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# Script lives at .claude/skills/sprint-start/scripts/ — repo root is 4 levels up
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
# Parent of repo root is where .sprint/ and the DB live
|
||||
PARENT="$(dirname "$REPO_ROOT")"
|
||||
|
||||
# Resolve sprint number — argument or active sprint from DB
|
||||
if [ -n "${1:-}" ]; then
|
||||
SPRINT="$1"
|
||||
else
|
||||
SPRINT=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT id FROM sprints WHERE status='active'" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['rows'][0]['id'])" 2>/dev/null || true)
|
||||
if [ -z "$SPRINT" ]; then
|
||||
echo "error: no active sprint found. Pass a sprint number or activate a sprint first." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Query teams with open tickets
|
||||
TEAMS=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT DISTINCT team FROM tickets WHERE sprint_id=$SPRINT AND status NOT IN ('done','cancelled') AND team IS NOT NULL" 2>/dev/null | python3 -c "import sys,json; [print(r['team']) for r in json.load(sys.stdin)['rows'] if r['team']]" 2>/dev/null || true)
|
||||
|
||||
if [ -z "$TEAMS" ]; then
|
||||
echo "Sprint $SPRINT has no open tickets. Opening main only."
|
||||
fi
|
||||
|
||||
echo "Sprint $SPRINT — teams: ${TEAMS:-none}"
|
||||
|
||||
# ── Open team tabs in the CURRENT window ────────────────────────────
|
||||
# No separate main tab — the caller is already on main.
|
||||
# All team tabs open as --tab in the active Ptyxis window.
|
||||
for team in $TEAMS; do
|
||||
BRANCH="sprint-${SPRINT}/${team}"
|
||||
WDIR="$PARENT/.sprint/sprint-${SPRINT}/${team}"
|
||||
|
||||
# Create worktree if it doesn't exist
|
||||
if [ ! -d "$WDIR" ]; then
|
||||
echo "Creating worktree: $WDIR (branch: $BRANCH)"
|
||||
mkdir -p "$(dirname "$WDIR")"
|
||||
# Create branch from main if it doesn't exist remotely
|
||||
if git -C "$REPO_ROOT" rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
|
||||
git -C "$REPO_ROOT" worktree add "$WDIR" "$BRANCH"
|
||||
else
|
||||
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WDIR" HEAD
|
||||
fi
|
||||
fi
|
||||
|
||||
ptyxis --tab -d "$WDIR" -x 'tmux new-session \; send-keys "claude /sprint-start" Enter'
|
||||
sleep 0.3
|
||||
done
|
||||
|
||||
echo "Session ready. Main + ${TEAMS:-(no teams)}"
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: sprint-status
|
||||
description: >
|
||||
Sprint health check and cleanup sweep. Lists all tickets in the active
|
||||
sprint grouped by status, detects bookkeeping issues (stale tickets,
|
||||
orphan PRs, done-but-open PRs, unassigned work), and shows open work
|
||||
by team. Use when checking sprint progress, before sprint close, or
|
||||
when housekeeping feels off. Triggers on "sprint status", "cleanup
|
||||
sweep", "what's open", "sprint health".
|
||||
user-invocable: true
|
||||
allowed-tools: Task, Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Sprint Status
|
||||
|
||||
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
|
||||
|
||||
When this skill is invoked, spawn a subagent using the Task tool:
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "general-purpose",
|
||||
model: "haiku",
|
||||
prompt: "Run /sprint-status. Read the skill at
|
||||
.claude/skills/sprint-status/SKILL.md for the full workflow
|
||||
(below the --- separator), then execute it.",
|
||||
description: "Sprint status report"
|
||||
)
|
||||
```
|
||||
|
||||
Present the subagent's output to the user verbatim. Do NOT run the
|
||||
workflow yourself.
|
||||
|
||||
---
|
||||
|
||||
The remainder of this file is the subagent's reference for executing
|
||||
the workflow.
|
||||
|
||||
## Step 1 — Gather data
|
||||
|
||||
Run these two commands in parallel:
|
||||
|
||||
```bash
|
||||
tooling/db/sprint sweep
|
||||
```
|
||||
|
||||
```bash
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
The `sweep` command returns JSON with:
|
||||
- `sprint` — id, name, goal
|
||||
- `progress` — total, done, pct
|
||||
- `by_status` — tickets grouped into done, review, in_progress, blocked, backlog
|
||||
- `by_team` — per-team counts
|
||||
- `issues` — bookkeeping problems with suggested fix commands
|
||||
|
||||
The `tea pr list` returns open PRs as `#N title` lines.
|
||||
|
||||
## Step 2 — Cross-reference PRs with tickets
|
||||
|
||||
Parse PR head branches from the `tea pr list` output. Known team branches:
|
||||
`server`, `client`, `copy`, `audio`, `visual`, `ci`.
|
||||
|
||||
Detect additional issues:
|
||||
|
||||
- **done_team_open_pr**: A team's tickets are all done but an open PR
|
||||
still exists for that team branch.
|
||||
- **orphan_pr**: An open PR exists on a branch that has no tickets in
|
||||
the active sprint.
|
||||
|
||||
Add these to the issues list from step 1.
|
||||
|
||||
## Step 3 — Format output
|
||||
|
||||
Read `references/output-template.md` for the exact format spec.
|
||||
|
||||
Render the report using data from steps 1-2. Key rules:
|
||||
- Sections ordered: Completed, In Review, In Progress, Blocked, Backlog
|
||||
- Sort tickets within sections by team then ticket ID
|
||||
- Empty sections: show header with "(0)" and "(none)" — no empty table
|
||||
- Bookkeeping Issues: two-column table (Issue, Fix)
|
||||
- Open Work by Team: summary table at the bottom
|
||||
- Issue type labels: `stale_backlog` → "Stale backlog",
|
||||
`unassigned_in_progress` → "Unassigned in_progress",
|
||||
`assigned_but_done` → "Assigned but done",
|
||||
`done_team_open_pr` → "Done team with open PR",
|
||||
`orphan_pr` → "Orphan PR"
|
||||
|
||||
## Step 4 — Suggest actions
|
||||
|
||||
After the formatted report, if there are bookkeeping issues, add a
|
||||
"Suggested fixes" section with the fix command for each issue. Group
|
||||
by issue type for readability.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Sprint Status Output Template
|
||||
|
||||
## Sprint {N}: {Theme} — Status Report
|
||||
|
||||
**Goal:** {goal}
|
||||
**Status:** {status} | {done}/{total} tickets ({pct}%)
|
||||
**Open PRs:** {count} ({branches})
|
||||
|
||||
---
|
||||
|
||||
### Completed ({count})
|
||||
|
||||
| # | Team | Title | Assigned |
|
||||
|---|------|-------|----------|
|
||||
| #{id} | {team} | {title} | {assigned} |
|
||||
|
||||
### In Review ({count})
|
||||
|
||||
| # | Team | Title | PR |
|
||||
|---|------|-------|----|
|
||||
| #{id} | {team} | {title} | #{pr} |
|
||||
|
||||
### In Progress ({count})
|
||||
|
||||
| # | Team | Title | Assigned | Note |
|
||||
|---|------|-------|----------|------|
|
||||
| #{id} | {team} | {title} | {assigned} | |
|
||||
|
||||
### Blocked ({count})
|
||||
|
||||
| # | Team | Title | Blocked by |
|
||||
|---|------|-------|------------|
|
||||
| #{id} | {team} | {title} | #{ids} |
|
||||
|
||||
### Backlog ({count})
|
||||
|
||||
| # | Team | Title | Note |
|
||||
|---|------|-------|----|
|
||||
| #{id} | {team} | {title} | not started |
|
||||
|
||||
---
|
||||
|
||||
### Bookkeeping Issues
|
||||
|
||||
| Issue | Fix |
|
||||
|-------|-----|
|
||||
| {type}: {detail} | `{command}` |
|
||||
|
||||
### Open Work by Team
|
||||
|
||||
| Team | Backlog | In Progress | Review | Blocked | Done |
|
||||
|------|---------|-------------|--------|---------|------|
|
||||
| {team} | {n} | {n} | {n} | {n} | {n} |
|
||||
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: ticket
|
||||
description: >
|
||||
Manage project tickets in the SQLite ticketing database. Use when the user
|
||||
says "ticket", "create a ticket", "show tickets", "sprint", or invokes /ticket.
|
||||
Wraps the ticket CLI for structured project management operations.
|
||||
Manage project tickets and milestones in the SQLite ticketing database. Use
|
||||
when the user says "ticket", "create a ticket", "show tickets", "milestone",
|
||||
or invokes /ticket. Wraps the ticket CLI for structured project management.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob
|
||||
---
|
||||
@@ -17,12 +17,12 @@ section. This skill covers the full command reference.
|
||||
|
||||
### List tickets (full flags)
|
||||
```bash
|
||||
tooling/db/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
|
||||
tooling/db/ticket list [--status S] [--priority P] [--epic N] [--milestone N] [--assigned A] [--team T]
|
||||
```
|
||||
|
||||
### Create ticket
|
||||
```bash
|
||||
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T]
|
||||
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
|
||||
```
|
||||
Types: `initiative`, `epic`, `story`, `task`, `bug`
|
||||
Priorities: `critical`, `high`, `medium`, `low`
|
||||
@@ -46,18 +46,27 @@ tooling/db/ticket team <id> <teams>
|
||||
```
|
||||
Teams are comma-separated, e.g. `server`, `client`, `server,client`.
|
||||
|
||||
### Sprint management
|
||||
```bash
|
||||
tooling/db/ticket sprint [--active]
|
||||
tooling/db/ticket sprint assign <id> <sprint_id>
|
||||
```
|
||||
|
||||
For sprint-scoped operations (status overview, context dumps, lifecycle),
|
||||
use the dedicated sprint CLI instead: `tooling/db/sprint --help`
|
||||
|
||||
### Dependencies
|
||||
```bash
|
||||
tooling/db/ticket deps <id>
|
||||
tooling/db/ticket dep add <blocker_id> <blocked_id>
|
||||
tooling/db/ticket dep rm <blocker_id> <blocked_id>
|
||||
```
|
||||
|
||||
### WIP
|
||||
```bash
|
||||
tooling/db/ticket wip
|
||||
```
|
||||
|
||||
### Milestones
|
||||
```bash
|
||||
tooling/db/ticket milestone list [--status S]
|
||||
tooling/db/ticket milestone create <name> [--description TEXT] [--phase N]
|
||||
tooling/db/ticket milestone link <ticket_id> <milestone_id>
|
||||
tooling/db/ticket milestone unlink <ticket_id> <milestone_id>
|
||||
tooling/db/ticket milestone complete <milestone_id>
|
||||
tooling/db/ticket milestone show <milestone_id>
|
||||
tooling/db/ticket milestone dep <blocker_id> <blocked_id>
|
||||
```
|
||||
|
||||
### Search and browse
|
||||
@@ -75,10 +84,10 @@ tooling/db/ticket show --brief <id> [<id>...]
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **SI (Project Manager)** is the primary user of this skill
|
||||
2. Decisions from decisions/ domain files become **initiatives**
|
||||
3. SI breaks initiatives into **epics** (major work areas)
|
||||
4. Epics break into **stories** (user-facing deliverables)
|
||||
5. Stories break into **tasks** (concrete work items assignable to agents)
|
||||
6. **Sprints** group tasks into time-boxed work periods
|
||||
1. Decisions from `decisions/` domain files become **initiatives**
|
||||
2. Initiatives break into **epics** (major work areas)
|
||||
3. Epics break into **stories** (user-facing deliverables)
|
||||
4. Stories break into **tasks** (concrete work items)
|
||||
5. **Milestones** group tickets by deliverable (many-to-many via `milestone link`)
|
||||
6. `/whats-next` selects the next batch from the dependency graph
|
||||
7. Track history via `ticket_history` table for audit trail
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: whats-next
|
||||
description: >
|
||||
Analyze the milestone dependency web and surface the best batch of tickets
|
||||
to pick up next. Refines ticket context via parallel Si agents before
|
||||
activation. Use when the user says "what's next", "next batch", "pick up
|
||||
work", or invokes /whats-next. NOT triggered by "what should we work on"
|
||||
in a design context — that's a discussion, not a batch selection.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, Agent, AskUserQuestion
|
||||
---
|
||||
|
||||
# What's Next
|
||||
|
||||
Dependency-driven batch selection with ticket refinement. Three steps:
|
||||
batch selection → refinement review → batch activation.
|
||||
|
||||
## Step 1: Batch Selection
|
||||
|
||||
### 1a. Query active milestones
|
||||
|
||||
```bash
|
||||
tooling/db/ticket milestone list --status active
|
||||
```
|
||||
|
||||
If no active milestones exist, tell the user and stop.
|
||||
|
||||
### 1b. Build the work landscape
|
||||
|
||||
For each active milestone, get its tickets:
|
||||
|
||||
```bash
|
||||
tooling/db/ticket milestone show <milestone_id>
|
||||
```
|
||||
|
||||
Collect all tickets across all active milestones. Deduplicate (a ticket
|
||||
can appear in multiple milestones).
|
||||
|
||||
### 1c. Identify unblocked tickets
|
||||
|
||||
For each ticket that is `ready` or `backlog`, check its dependencies:
|
||||
|
||||
```bash
|
||||
tooling/db/ticket deps <id>
|
||||
```
|
||||
|
||||
A ticket is **unblocked** if all its blockers are `done` or `cancelled`.
|
||||
Filter to only unblocked tickets.
|
||||
|
||||
### 1d. Check milestone-level blocking
|
||||
|
||||
```bash
|
||||
tooling/db/sqlite-query "SELECT * FROM milestone_deps"
|
||||
```
|
||||
|
||||
If a milestone is blocked by another milestone that is not yet `completed`,
|
||||
deprioritize its tickets (they're unblocked at the ticket level but the
|
||||
milestone isn't ready for focus yet). Still show them, but ranked lower.
|
||||
|
||||
### 1e. Rank and group
|
||||
|
||||
Rank unblocked tickets by:
|
||||
1. **Priority** (critical > high > medium > low)
|
||||
2. **Milestone proximity** — milestones closest to completion (highest
|
||||
done/total ratio) get priority. Finishing a milestone unlocks downstream
|
||||
milestone deps.
|
||||
3. **Fan-out** — tickets that unblock the most downstream tickets rank
|
||||
higher (query `ticket_deps` for blocked_id counts per blocker_id)
|
||||
|
||||
Group into **epic-sized batches**: tickets sharing the same `parent_id`
|
||||
(epic), or logically related tickets if no epic parent. If a natural
|
||||
grouping doesn't exist, batch by milestone.
|
||||
|
||||
### 1f. Show WIP status
|
||||
|
||||
```bash
|
||||
tooling/db/ticket wip
|
||||
```
|
||||
|
||||
### 1g. Present the recommended batch
|
||||
|
||||
Show the user:
|
||||
- The recommended batch (tickets with IDs, titles, priorities, milestones)
|
||||
- Why this batch (which milestone it advances, what it unblocks)
|
||||
- Current WIP status
|
||||
- Any alternative batches worth considering
|
||||
|
||||
Wait for user confirmation before proceeding to Step 2.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Ticket Refinement Review
|
||||
|
||||
Spawn one Si agent per ticket in the confirmed batch, running in parallel.
|
||||
Use `general-purpose` subagent_type with Si's personality baked into the
|
||||
prompt. (The `si` subagent_type now works directly too — all agent
|
||||
definitions carry the team coordination tools, SendMessage + Task, as of the
|
||||
agent-teams tooling fix; they load at lead startup.)
|
||||
|
||||
For each ticket, spawn:
|
||||
|
||||
```
|
||||
Agent({
|
||||
subagent_type: "general-purpose",
|
||||
model: "sonnet",
|
||||
prompt: "You are SI, the Refinement Manager. <include si.md personality>
|
||||
|
||||
Review ticket #<id>: <title>
|
||||
Description: <description>
|
||||
Decision ref: <decision_ref>
|
||||
|
||||
Your job:
|
||||
1. Read the ticket's decision_ref D/Q-record in decisions/*.md
|
||||
2. Grep for related Q-records in decisions/questions-*.md
|
||||
3. Read workshop outcomes if referenced (check docs/workshops/)
|
||||
4. Check whether referenced code, tables, or files actually exist
|
||||
|
||||
Assess: does this ticket have enough context for an agent to implement
|
||||
without guessing? Report exactly one of:
|
||||
- READY: <one-paragraph summary of what the implementer needs to know>
|
||||
- GAPS: <list of specific ambiguities with options for each>"
|
||||
})
|
||||
```
|
||||
|
||||
### 2a. Collect reports
|
||||
|
||||
Wait for all Si agents to complete. Collect their reports.
|
||||
|
||||
### 2b. Resolve gaps
|
||||
|
||||
For any ticket that came back GAPS, surface each ambiguity to the user
|
||||
via AskUserQuestion:
|
||||
|
||||
> Ticket #NNN: <title>
|
||||
> Si found these gaps:
|
||||
> 1. <gap description + options>
|
||||
> 2. <gap description + options>
|
||||
>
|
||||
> How should we resolve these?
|
||||
|
||||
After the user responds, append the resolution to the ticket description:
|
||||
|
||||
```bash
|
||||
tooling/db/sqlite-exec "UPDATE tickets SET description = description || char(10) || char(10) || '---' || char(10) || 'Refinement: <resolution>' WHERE id = <id>"
|
||||
```
|
||||
|
||||
If the user says a gap requires a D-record, flag it and ask whether to
|
||||
write the D-record now or defer. If deferred, add a note to the ticket.
|
||||
|
||||
### 2c. Present refined batch summary
|
||||
|
||||
Show each ticket with:
|
||||
- Si's context summary (for READY tickets) or user's resolution (for GAPS)
|
||||
- Relevant D-record references
|
||||
- Dependencies
|
||||
|
||||
Ask: "Batch ready. Activate?"
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Batch Activation
|
||||
|
||||
### 3a. Mark tickets in_progress
|
||||
|
||||
```bash
|
||||
tooling/db/ticket status <id> in_progress
|
||||
```
|
||||
|
||||
For each ticket in the batch. The `status` command will warn if WIP limit
|
||||
is exceeded.
|
||||
|
||||
### 3b. Create topic branch
|
||||
|
||||
Name the branch after the epic or logical grouping. Examples:
|
||||
- `tier0-schema-tables` (for a schema epic)
|
||||
- `heightmap-import` (for a single focused ticket)
|
||||
- `city-names-pipeline` (for related importer tickets)
|
||||
|
||||
```bash
|
||||
git checkout -b <branch-name>
|
||||
```
|
||||
|
||||
### 3c. Create worktree (optional)
|
||||
|
||||
If the user wants to work in a separate worktree:
|
||||
|
||||
```bash
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
WORKTREE_DIR="$(dirname "$REPO_ROOT")/.worktrees/<branch-name>"
|
||||
git worktree add "$WORKTREE_DIR" <branch-name>
|
||||
```
|
||||
|
||||
Report the worktree path so the user can open a terminal there.
|
||||
|
||||
### 3d. Spawn implementation agents (optional)
|
||||
|
||||
If the user wants agents, spawn them using general-purpose subagent_type
|
||||
with the relevant personality baked into the prompt. Include:
|
||||
- The ticket details and Si's context summary
|
||||
- Relevant D-record content (read and include, don't just reference)
|
||||
- The RULES block (no git commands, write files only, message when blocked)
|
||||
- Model: sonnet (per team-mode pin rule)
|
||||
|
||||
### 3e. Report
|
||||
|
||||
Summarize what was activated:
|
||||
- Branch name
|
||||
- Worktree path (if created)
|
||||
- Tickets now in_progress
|
||||
- Agents spawned (if any)
|
||||
- Next step: work, then `/pr-process` when ready to ship
|
||||
@@ -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.
|
||||
|
||||
+91
-6
@@ -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
|
||||
@@ -74,10 +87,12 @@ elif command -v cargo >/dev/null 2>&1 && [ -d "$REPO_ROOT/server" ]; then
|
||||
echo "pre-push: fmt — OK"
|
||||
fi
|
||||
|
||||
# clippy needs a build — skip if target/ doesn't exist (cold worktree)
|
||||
# clippy needs a build — skip if target/ doesn't exist (cold worktree).
|
||||
# --all-targets lints tests + examples too (#967 closed the gap where test
|
||||
# code accumulated clippy debt unchecked).
|
||||
if [ -d "$REPO_ROOT/server/target" ]; then
|
||||
echo "pre-push: checking Rust (clippy)..."
|
||||
if ! (cd "$REPO_ROOT/server" && cargo clippy -- -D warnings 2>&1); then
|
||||
if ! (cd "$REPO_ROOT/server" && cargo clippy --all-targets -- -D warnings 2>&1); then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
else
|
||||
echo "pre-push: clippy — OK"
|
||||
@@ -114,7 +129,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 +158,71 @@ 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
|
||||
|
||||
# --- Clerk review (D-221) ---
|
||||
# DISABLED 2026-05-23 (#965) pending rework. Two problems made it net-negative:
|
||||
# 1. Non-exhaustive — a single run reports ~the first contradiction it finds
|
||||
# and stops, so distinct real issues surfaced only on the 2nd/3rd/4th
|
||||
# re-push. An APPROVED verdict therefore can't be trusted as "clean".
|
||||
# 2. Re-reviews the entire origin/main..HEAD range on every push (token burn);
|
||||
# no per-commit verdict cache.
|
||||
# Off by default until reworked (exhaustive enumeration + holistic decisions/
|
||||
# diff check + a SHA-keyed verdict cache invalidated on decisions/ change, with
|
||||
# skip tracked in the cache rather than a commit-message trailer — see #965).
|
||||
# Force-run with SR_RUN_CLERK=1.
|
||||
if [ -x "$REPO_ROOT/tooling/clerk-review" ] && [ "${SR_RUN_CLERK:-0}" = "1" ]; then
|
||||
echo "pre-push: running clerk review..."
|
||||
CLERK_VERDICT=$("$REPO_ROOT/tooling/clerk-review" 2>&1 | tee /dev/stderr | tail -1)
|
||||
if [ "$CLERK_VERDICT" = "APPROVED" ]; then
|
||||
echo "pre-push: clerk — APPROVED"
|
||||
elif [ "$CLERK_VERDICT" = "REJECTED" ]; then
|
||||
echo "pre-push: clerk — REJECTED (see .cache/pre-push-review.md)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
elif [ "$CLERK_VERDICT" = "INCOMPLETE" ]; then
|
||||
echo "pre-push: clerk — INCOMPLETE (some reviews didn't finish; NOT blocking)"
|
||||
echo " See .cache/pre-push-review.md. For a full verdict: raise SR_CLERK_MAX_TURNS / SR_CLERK_TIMEOUT,"
|
||||
echo " or add a 'Clerk-Skip:' trailer to bulk-content commits."
|
||||
else
|
||||
echo "pre-push: clerk — '$CLERK_VERDICT' unrecognized; treating as block (see .cache/pre-push-review.md)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
else
|
||||
echo "pre-push: clerk review — DISABLED (#965; force-run with SR_RUN_CLERK=1)"
|
||||
fi
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "pre-push: $ERRORS check(s) failed. Push aborted."
|
||||
|
||||
@@ -6,6 +6,103 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Kanban + milestones workflow** (D-221) — replaces sprint-based workflow with dependency-driven batch selection, many-to-many milestones, `/whats-next` skill with Si refinement review, `/pr-process` skill (author-side review comment pickup), clerk pre-push hook for D-record consistency
|
||||
- **Atlas orbital redesign** — horizontal orrery layout with half-sun on left, globe textures loaded from wiki, radius-based body sizing (Earth = 24px reference, unclamped), partial orbital arcs
|
||||
- **Globe texture coverage** — 100% of 3240 bodies now have globe.png; generated oort cloud + asteroid belt generics via Gemini, donor-filled 40 missing bodies from same-class pool
|
||||
- **Body radius scatter** — deterministic ±scatter on `body_radius_km` seeded by body_id; gas giants 40k-60k km, moons 200-2600 km, Sol system uses real radii; exported to `star_map_data.json`
|
||||
- **Atlas interaction** — double-click system on reach map opens orbital view, double-click body opens regional heightmap, M closes atlas from any screen, ESC navigates back through screen stack
|
||||
- **Inline body detail panel** — clicking a body in the orbital view shows an info panel on the right with full-size globe artwork, body stats, and population; clicking another body swaps the panel
|
||||
- **Regional atlas wiki loading** — heightmaps and markers load directly from the wiki filesystem, fixing "TERRAIN DATA PENDING" for all bodies with authored heightmaps
|
||||
- **Agent-team coordination tools** — `SendMessage` plus `TaskList`/`TaskUpdate`/`TaskGet` added to all 21 agent definitions so custom subagents work as full teammates in tmux split panes (message the lead, claim/complete tasks, shut down cleanly); `TaskCreate` deliberately withheld to keep task creation with the lead
|
||||
- **`git-centralize-guard` hook** — PreToolUse hook blocking `.git`-mutating commands for agent-team teammates, keying on the `agent_type` field present only in teammate hook input; version control stays centralized to the team lead
|
||||
- **Gemma naming methodology doc** (`docs/gemma-naming-methodology.md`) — preserves the corridor-aware LLM place-naming approach (sector palettes, few-shot prompting, dedup, Earth-major blocklist, deterministic fallback) as institutional knowledge after the naming pipeline's retirement
|
||||
- **Layer-1 world topography cascade** (#953, D-208/209/210) — deterministic empty-world generation in Rust: D8 priority-flood drainage (rivers/basins, ~45 ms/body after optimization), 7 geographic settlement-attractor tags, and sub-biome + terrain-modification-cost classification. The foundation the settlement-placement layer builds on
|
||||
- **Canonical 16-bit heightmaps** (#963, D-202) — every body's elevation is now a per-body 16-bit grayscale `heightmap.png` (1024×512, `sea_level` in a tEXt chunk); the old color render is renamed `reliefmap.png`. Single source of truth the generation cascade reads
|
||||
- **Deterministic generation cascade** (#952, D-224/D-200) — `SeedChain`, a single splitmix64-based seed-derivation contract descending from the master world seed with domain separation (replaces ad-hoc seed mixing); an extensible per-layer cascade harness (`run_cascade`); and a golden-seed regression test pinning Layer 0→1 output (source heightmap SHA-256 + serialized topography) so any determinism drift is caught
|
||||
|
||||
### Changed
|
||||
- `teammateMode` set to `auto` — teammates spawn in tmux split panes when the lead runs inside tmux, with graceful in-process fallback
|
||||
- **Planet simulation native resolution → 1024×512** (#963) — finer base terrain for the lower cascade layers (deterministic; same continents, more detail). Rivers are now computed by the cascade's drainage rather than painted by the sim, so they actually reach the sea
|
||||
- **markers.json → names-only flavored pool** (D-223, #951) — all 2,398 bodies carry place *names* only, no positions or geometry; river/mountain positions derive from the heightmap + drainage, settlement positions from the economic sim + placement, with pooled names attached at generation time. The six hand-authored "templates" dissolve into ordinary named bodies
|
||||
- **`import_economics` owns the atlas index** (#951) — now the sole `regen-db` generator that touches the atlas tables; loads the names pool into `atlas_city_names` and empties the geometry tables, which the Phase 4 server cascade fills
|
||||
- **Rust dependency maintenance** (#966) — `rand` → 0.9.4 (clears the RUSTSEC-2026-0097 unsound advisory), a ~90-package semver-compatible freshness sweep (bevy 0.18.1, clap 4.6, others), and the deprecated `serde_yaml` replaced with the maintained `serde_norway` drop-in fork. No CVEs outstanding
|
||||
- **Pre-push clippy now lints tests + examples** (#967) — added `--all-targets`, closing the gap that let test-code lint debt accumulate unchecked; the existing cfg(test)/test-target clippy-1.93 debt was cleared in the same pass
|
||||
|
||||
### Fixed
|
||||
- Godot 4.6 type inference parse errors in `main.gd` (`var zoom`, `var speed`) that prevented the main scene from loading
|
||||
- `physical_keycode` used throughout for layout-independent key handling (was `keycode`, broke on non-US layouts)
|
||||
|
||||
### Removed
|
||||
- Sprint CLI (`tooling/db/sprint`), sprint skills (`/sprint-start`, `/sprint-plan`, `/sprint-status`), team-scoped file restrictions, sprint briefing workflow
|
||||
- **Atlas geometry generator + LLM naming pipeline** (#951, D-223) — `generate_atlas.py`, `gemma_naming.py`, `naming_core.py`, `apply_name_fixes.py`, the redundant `import_city_names.py`, their tests/QA/runner scripts, and the `fix_fewshot_bleed`/`prune_atlas_features` geometry tools. Procedural placement and authored names are superseded by the deterministic server cascade and the frozen name pool
|
||||
- **`atlas_body_heightmaps` DB table** (#963, D-202) — elevation moved out of `systems.db` into per-body PNG files (avoids binary merge conflicts and DB bloat); the Rust cascade loads the file
|
||||
|
||||
## [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
|
||||
|
||||
@@ -4,7 +4,7 @@ A top-down life-sim — asymmetric information, occlusion-based perception, sing
|
||||
|
||||
**Official Title:** The Settled Reach
|
||||
**Repository name:** settled-reach
|
||||
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
||||
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.{phase}.{n}`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -13,7 +13,7 @@ client/ # Godot 4 client
|
||||
server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
docs/ # Architecture, design, briefings, sprints, workshops
|
||||
docs/ # Architecture, design, briefings, workshops
|
||||
db/ # Schema + seed data (connectors moved to tooling/db/)
|
||||
.claude/ # Agents, skills, rules
|
||||
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
|
||||
@@ -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.
|
||||
@@ -34,16 +40,18 @@ Development follows a strict cascade. Each phase has a concrete deliverable. **D
|
||||
| 1 | Wiki content complete — all planets, moons, stations, heightmaps, artwork | Implant-ready Godot map of the Reach with click-throughs + wiki/GTTR popups |
|
||||
| 2 | Economics layer — supply/demand, transport, political/social pressure, corporations, supply chains | Economics spreadsheets/graphs with runtime-tweakable simulation |
|
||||
| 3 | Planetary/moon maps & station layouts — cities, rivers, mountains, roads, biomes, rail | Atlas of the Reach (implant app) |
|
||||
| 4 | Player control scheme — 2-floor test map, character rendering, walls/stairs/doors, lighting | Player viewport with final-version assets |
|
||||
| 5 | World generation (tile/chunk/block) — walkable world, parallel asset pipeline | Walkable generated world + asset catalog |
|
||||
| 4 | World generation (tile/chunk/block) — deterministic multilayer seed→tile cascade; each layer viewed as a **map in the implant Atlas** (no in-world rendering) | Deterministic walkable-world data + per-layer Atlas maps + asset catalog |
|
||||
| 5 | Player control & in-world rendering — character, walls/stairs/doors, lighting, drawn on **generated** tiles (no test map) | Player viewport with final-version assets on the generated world |
|
||||
| 6 | Detail coloring — room-level NPC population, cultural room grammar | Only when the world is walkable |
|
||||
|
||||
**v0.2 target is dropped.** No scoping negotiations. Build the base systems fully.
|
||||
|
||||
**Generation before player (D-166, amended 2026-05-22).** No player-control or in-world rendering work begins until the generator can deterministically seed-generate every tile of every world via the full multilayer cascade. There is **no hand-made test map** — test layers are produced by the generator itself once layer-drawing begins. Progress is viewed through the implant **Atlas** (per-layer maps), which already exists (Phase 3). The current in-world rendering code is **left as-is until Phase 5** — do not build on it or rip it out before then.
|
||||
|
||||
## Work Modes
|
||||
|
||||
### Sprint mode (default)
|
||||
Agents work autonomously on sprint branches. Team lead coordinates via tasks and messages. Human reviews PRs from main. Standard `/sprint-start` → `/pr-push` → `/pr-review` lifecycle.
|
||||
### Kanban mode (default)
|
||||
Work flows continuously through milestones. `/whats-next` selects the next epic-sized batch from the dependency graph, refines ticket context via parallel Si agents, then activates a worktree. `/pr-process` (from worktree) and `/pr-review` (from main) handle the review cycle through Gitea PR comments. Milestones are many-to-many with tickets, can run in parallel, and can block other milestones.
|
||||
|
||||
### Pair session
|
||||
Human and Claude work together interactively on a single task. No background agents, no autonomous work. Used for load-bearing architecture changes where the human needs to make judgment calls as the work progresses — not approve a finished result.
|
||||
@@ -59,13 +67,10 @@ Human and Claude work together interactively on a single task. No background age
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
### Team boundaries
|
||||
|
||||
**Your team is determined by your sprint branch** (e.g. `sprint-31/server` → server team).
|
||||
### Agent scope
|
||||
|
||||
- All file paths are relative to the repo root (e.g. `server/src/bridge/types.rs`).
|
||||
- **Stay within your team's scope.** Server team modifies `server/`. Client team modifies `client/`. Copy team modifies `wiki/`, `docs/atlas/`, `content/`. Shared directories (`docs/`, `decisions/`) are readable by all teams.
|
||||
- **Do NOT modify files outside your team scope** unless the ticket explicitly requires it.
|
||||
- Topic branches per logical work unit (e.g. `heightmap-import`, `city-names-table`). Any agent can touch any file — personalities guide perspective, not file access.
|
||||
- **Never chain git commands** in a single Bash call (e.g. `git add ... && git commit ...`). Always run `git add` and `git commit` as **separate sequential Bash calls**.
|
||||
- **Stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file at `.git/index.lock` (or `.git/worktrees/<name>/index.lock` if in a worktree).
|
||||
|
||||
@@ -74,10 +79,11 @@ Human and Claude work together interactively on a single task. No background age
|
||||
The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var (set in `.claude/settings.json`). A backup is committed to `docs/backups/settledreach.db.backup` via main only.
|
||||
|
||||
### Before starting work
|
||||
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
|
||||
2. Use `tooling/db/ticket show <id>` for full ticket details
|
||||
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
|
||||
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
||||
1. Check active work: `tooling/db/ticket list --status in_progress`
|
||||
2. Check your ticket: `tooling/db/ticket show <id>`
|
||||
3. Check dependencies: `tooling/db/ticket deps <id>`
|
||||
4. Read relevant `decisions/*.md` domain file(s) referenced in the ticket
|
||||
5. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
|
||||
|
||||
### CLI tools
|
||||
|
||||
@@ -86,10 +92,10 @@ The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var
|
||||
| Tool | Command | Full reference |
|
||||
|------|---------|----------------|
|
||||
| Tickets | `tooling/db/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
|
||||
| Sprints | `tooling/db/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
|
||||
| Milestones | `tooling/db/ticket milestone list`, `create`, `link`, `show` | `/ticket` 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 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"
|
||||
@@ -57,7 +58,9 @@ help:
|
||||
@echo " make star-map-data Regenerate client/data/star_map_data.json from systems.db + wiki"
|
||||
@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 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 +113,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
|
||||
@@ -202,8 +209,9 @@ golden-diff:
|
||||
fi
|
||||
|
||||
golden-update:
|
||||
@echo "Regenerating golden file..."
|
||||
@echo "Regenerating golden files..."
|
||||
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden
|
||||
@cd server && UPDATE_GOLDEN=1 cargo test --test cascade_golden
|
||||
@git add server/tests/golden/
|
||||
@echo "--- Golden file updated and staged ---"
|
||||
@echo "Review with: git diff --cached -- server/tests/golden/"
|
||||
@@ -333,19 +341,27 @@ economy-db: ## Import economics data (commodities, chains, gate links) into sys
|
||||
@tooling/generate-brands
|
||||
@python3 tooling/economy-db/import_economics.py
|
||||
|
||||
atlas-generate: ## Generate atlas markers (cities, roads, rail) for all inhabited bodies (#832)
|
||||
@# Loud guard: generate_atlas.py reads bodies with a non-NULL terrain_reference.
|
||||
@# If populate_terrain_reference.py has not run on a fresh DB, the generator
|
||||
@# silently processes zero bodies and exits 0 — fail fast instead.
|
||||
@count=$$(python3 -c "import sqlite3; c = sqlite3.connect('server/data/systems.db'); print(c.execute('SELECT COUNT(*) FROM bodies WHERE terrain_reference IS NOT NULL').fetchone()[0])"); \
|
||||
if [ "$$count" = "0" ]; then \
|
||||
echo "ERROR: no bodies have terrain_reference populated yet."; \
|
||||
echo "Run: python3 tooling/planet-gen/populate_terrain_reference.py"; \
|
||||
echo "(This is a prerequisite for atlas-generate — see D-191 §9 pipeline order.)"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
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. The atlas city/road/river geometry generator
|
||||
@# was retired in #951 (D-223); import_economics now owns the atlas
|
||||
@# index — it loads the names-only pool into atlas_city_names and empties
|
||||
@# the geometry tables (the server cascade fills them, Phase 4).
|
||||
@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 ""; \
|
||||
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
|
||||
@@ -364,7 +380,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 +388,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:
|
||||
|
||||
+8864
-5624
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
|
||||
+46
-4
@@ -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,16 +189,39 @@ 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: Vector2 = 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():
|
||||
push_warning("main.gd: manifest with empty app_path — skipping")
|
||||
continue
|
||||
if manifest.default_key == key_event.keycode:
|
||||
if manifest.default_key == key_event.physical_keycode:
|
||||
HudGroups.toggle_app(
|
||||
manifest.app_path,
|
||||
ImplantRegistry.get_resolved_mode(manifest.app_path)
|
||||
@@ -202,10 +230,10 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
# [ / ] — in-app navigation for the economics monitor. Not manifest-declared because
|
||||
# these control intra-app navigation (prev/next system), not app launch. A planned
|
||||
# handle_global_key lifecycle hook will absorb this (see arch doc Follow-up).
|
||||
if key_event.keycode == KEY_BRACKETLEFT:
|
||||
if key_event.physical_keycode == KEY_BRACKETLEFT:
|
||||
if economics_app and HudGroups.is_app_active("implant/economics"):
|
||||
economics_app.navigate(-1)
|
||||
elif key_event.keycode == KEY_BRACKETRIGHT:
|
||||
elif key_event.physical_keycode == KEY_BRACKETRIGHT:
|
||||
if economics_app and HudGroups.is_app_active("implant/economics"):
|
||||
economics_app.navigate(1)
|
||||
|
||||
@@ -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: float = 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:
|
||||
|
||||
@@ -62,15 +62,19 @@ func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if not event.is_pressed() or event.is_echo():
|
||||
return
|
||||
if current_screen_id() == "regional":
|
||||
return # AtlasViewer handles its own keyboard input
|
||||
return
|
||||
_handle_key(event as InputEventKey)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _handle_key(event: InputEventKey) -> void:
|
||||
match event.keycode:
|
||||
match event.physical_keycode:
|
||||
KEY_M:
|
||||
HudGroups.close_app()
|
||||
KEY_ESCAPE:
|
||||
if current_screen_id() == "reach":
|
||||
if current_screen_id() == "system" and _system_screen and (_system_screen.has_body_panel_open() or _system_screen.has_station_panel_open()):
|
||||
_system_screen.close_panels()
|
||||
elif current_screen_id() == "reach":
|
||||
HudGroups.close_app()
|
||||
else:
|
||||
nav.pop()
|
||||
@@ -93,18 +97,13 @@ func _handle_enter() -> void:
|
||||
if _system_screen and not _system_screen.is_in_orbital():
|
||||
var sys: Dictionary = _system_screen.current_system()
|
||||
nav.replace("system", {"mode": "orbital", "system": sys})
|
||||
"planet":
|
||||
if _planet_screen and _planet_screen.has_heightmap():
|
||||
(
|
||||
nav
|
||||
. push(
|
||||
"regional",
|
||||
{
|
||||
"body": _planet_screen.current_body(),
|
||||
"system": nav.current_payload().get("system", {}),
|
||||
}
|
||||
)
|
||||
)
|
||||
elif _system_screen and _system_screen.has_body_panel_open():
|
||||
var body: Dictionary = _system_screen.get_selected_body()
|
||||
if body.get("terrain_reference") != null:
|
||||
nav.push("regional", {
|
||||
"body": body,
|
||||
"system": nav.current_payload().get("system", {}),
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -121,16 +120,10 @@ func _on_system_selected(system_id: String) -> void:
|
||||
|
||||
|
||||
func _on_body_selected(body: Dictionary) -> void:
|
||||
(
|
||||
nav
|
||||
. push(
|
||||
"planet",
|
||||
{
|
||||
"body": body,
|
||||
"system": nav.current_payload().get("system", {}),
|
||||
}
|
||||
)
|
||||
)
|
||||
nav.push("regional", {
|
||||
"body": body,
|
||||
"system": nav.current_payload().get("system", {}),
|
||||
})
|
||||
|
||||
|
||||
func _on_regional_back() -> void:
|
||||
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -98,12 +98,6 @@ const OVERLAY_DEFS: Array = [
|
||||
"group": "toggle",
|
||||
"tooltip": "Production zones (D-181 observable — requires presence)."
|
||||
},
|
||||
{
|
||||
"id": "shadow_economy",
|
||||
"label": "SHD",
|
||||
"group": "toggle",
|
||||
"tooltip": "Shadow economy zones (D-181 observable)."
|
||||
},
|
||||
{
|
||||
"id": "corp_presence",
|
||||
"label": "CRP",
|
||||
@@ -268,15 +262,19 @@ func _load_heightmap() -> void:
|
||||
var ref: Variant = _body.get("terrain_reference")
|
||||
if ref == null or str(ref).is_empty():
|
||||
return
|
||||
var path: String = str(ref)
|
||||
if not path.begins_with("res://"):
|
||||
path = "res://" + path.lstrip("/")
|
||||
if not ResourceLoader.exists(path):
|
||||
push_warning("AtlasViewer: terrain_reference not found at %s" % path)
|
||||
# terrain_reference points at <body>/heightmap.png — now the canonical
|
||||
# 16-bit GRAYSCALE elevation (D-202 amended, #963). For display we load the
|
||||
# color reliefmap.png that sits beside it.
|
||||
var path: String = str(ref).get_base_dir() + "/reliefmap.png"
|
||||
if not path.begins_with("/"):
|
||||
var project_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
|
||||
path = project_root + "/" + path
|
||||
if not FileAccess.file_exists(path):
|
||||
push_warning("AtlasViewer: reliefmap not found at %s" % path)
|
||||
return
|
||||
var tex: Variant = load(path)
|
||||
if tex is Texture2D:
|
||||
_heightmap_texture = tex
|
||||
var img := Image.load_from_file(path)
|
||||
if img != null:
|
||||
_heightmap_texture = ImageTexture.create_from_image(img)
|
||||
_tex_w = float(_heightmap_texture.get_width())
|
||||
_tex_h = float(_heightmap_texture.get_height())
|
||||
|
||||
@@ -292,8 +290,9 @@ func _load_markers() -> void:
|
||||
if ref == null or str(ref).is_empty():
|
||||
return
|
||||
var hm_path: String = str(ref)
|
||||
if not hm_path.begins_with("res://"):
|
||||
hm_path = "res://" + hm_path.lstrip("/")
|
||||
if not hm_path.begins_with("/"):
|
||||
var project_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
|
||||
hm_path = project_root + "/" + hm_path
|
||||
var dir: String = hm_path.get_base_dir()
|
||||
var markers_path: String = dir + "/markers.json"
|
||||
|
||||
@@ -386,6 +385,7 @@ func _build_screen_header() -> void:
|
||||
# theme-swap invariant violation.
|
||||
_screen_header = ImplantHeader.new()
|
||||
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
|
||||
_screen_header.custom_minimum_size.x = 320.0
|
||||
_screen_header.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_screen_header)
|
||||
if _implant_theme:
|
||||
@@ -418,6 +418,14 @@ static func _dict_str(d: Dictionary, key: String, fallback: String) -> String:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
func _is_over_ui(pos: Vector2) -> bool:
|
||||
if _overlay_bar and _overlay_bar.get_global_rect().has_point(pos):
|
||||
return true
|
||||
if _city_panel and _city_panel.visible and _city_panel.get_global_rect().has_point(pos):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.is_echo():
|
||||
_handle_key(event as InputEventKey)
|
||||
@@ -426,6 +434,9 @@ func _gui_input(event: InputEvent) -> void:
|
||||
if _heightmap_texture == null:
|
||||
return
|
||||
|
||||
if event is InputEventMouseButton and _is_over_ui((event as InputEventMouseButton).global_position):
|
||||
return
|
||||
|
||||
if event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
|
||||
@@ -441,6 +452,8 @@ func _gui_input(event: InputEvent) -> void:
|
||||
else:
|
||||
_dragging = false
|
||||
elif event is InputEventMouseMotion:
|
||||
if _is_over_ui((event as InputEventMouseMotion).global_position):
|
||||
return
|
||||
var mm := event as InputEventMouseMotion
|
||||
if _dragging:
|
||||
_view_offset = _drag_start_offset + (mm.position - _drag_start_mouse)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user