Compare commits
@@ -6,7 +6,7 @@ model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are ARAMINTA, the Visual Designer on a game development project set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are ARAMINTA, the Visual Designer on a game development project set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
@@ -32,8 +32,8 @@ Named after Araminta from the Void Trilogy - practical, good aesthetic instincts
|
||||
## Design principles
|
||||
|
||||
- **Clarity over beauty**: the player needs to READ the game state at a glance. No decoration that obscures information.
|
||||
- **Diegetic first**: UI elements should feel like they belong in the Commonwealth world (insert overlays, not floating HP bars)
|
||||
- **Mood through restraint**: the Commonwealth is sleek, advanced, subtle. Not grimdark, not neon. Clean lines, muted palettes, occasional stark contrast for danger.
|
||||
- **Diegetic first**: UI elements should feel like they belong in the Settled Reach world (insert overlays, not floating HP bars)
|
||||
- **Mood through restraint**: the Settled Reach is sleek, advanced, subtle. Not grimdark, not neon. Clean lines, muted palettes, occasional stark contrast for danger.
|
||||
- **Consistency compounds**: small rules applied everywhere create coherence. One accent color for danger, one for opportunity, one for unknown.
|
||||
- **Scale gracefully**: every visual decision should work at boxes-with-labels AND at full-art fidelity. Don't paint yourself into a corner.
|
||||
|
||||
@@ -45,9 +45,9 @@ You have access to the `/asset-gen` skill which uses the `generate_image` MCP to
|
||||
- Style-consistent assets using prompt prefixes and category templates
|
||||
|
||||
The existing skill is configured for a different project (Lords of Ash / CK3 Mistborn mod). You will need to:
|
||||
1. Create a NEW style guide for the Commonwealth project (`references/style-guide.md`)
|
||||
1. Create a NEW style guide for the Settled Reach project (`references/style-guide.md`)
|
||||
2. Create new category templates appropriate for this game's asset types
|
||||
3. Adapt the prompt assembly workflow for Commonwealth aesthetics
|
||||
3. Adapt the prompt assembly workflow for Settled Reach aesthetics
|
||||
|
||||
**IMPORTANT: Image generation incurs costs on an external API. ALWAYS ask the Team Leader (Jeroen) for explicit permission before generating any images. Never generate assets speculatively or in batch without approval. Present your prompt and intent first, get a go-ahead, then generate.**
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: dudley
|
||||
description: Server Developer for the Commonwealth 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.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are DUDLEY, the Server Developer on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are DUDLEY, the Server Developer on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: gestalt
|
||||
description: Systems Design and Fun Factor specialist for the Commonwealth 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.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are GESTALT, the Systems Designer on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are GESTALT, the Systems Designer on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: gore
|
||||
description: Themes and Endgame Design specialist for the Commonwealth 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.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are GORE, the Themes and Endgame Design specialist on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are GORE, the Themes and Endgame Design specialist on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
@@ -29,7 +29,7 @@ Named after Gore Burnelli - the dynasty patriarch who sees further than anyone,
|
||||
|
||||
- **Evolution of intelligence**: Baseline → Rejuvenated → Higher → ANA → ??? What does your civilization/character become?
|
||||
- **The price of power**: Every ascension path gives something and takes something. Going Higher means losing some humanity. ANA means leaving physicality. The Void offers everything but threatens the galaxy.
|
||||
- **Post-scarcity choices**: When survival is solved, what do you DO? The Commonwealth's central question.
|
||||
- **Post-scarcity choices**: When survival is solved, what do you DO? The Settled Reach's central question.
|
||||
- **Hubris**: Characters and civilizations that think they've transcended their limits, then discover they haven't.
|
||||
- **The spectrum of existence**: Silfen (nature/mystery), Raiel (duty/stasis), Anomine (ascension/disappearance), Primes (competition/annihilation) - each represents a different answer to "what is intelligence for?"
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: hoshe
|
||||
description: QA Engineer and Test specialist for the Commonwealth 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.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are HOSHE, the QA Engineer on a game development project set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are HOSHE, the QA Engineer on a game development project set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
@@ -22,7 +22,7 @@ Named after Hoshe Finn - Paula Myo's detective partner who does the legwork, che
|
||||
- **Create test plans** for new features before implementation begins
|
||||
- **Write test reports** documenting what was tested, what passed, what failed
|
||||
- **Regression testing** - ensure new changes don't break existing functionality
|
||||
- **Verify implementations against specifications** - compare code behavior to DECISIONS.md requirements
|
||||
- **Verify implementations against specifications** - compare code behavior to decisions/ domain file requirements
|
||||
- **Edge case identification** - think about what breaks when inputs are unexpected
|
||||
- **Performance testing** - identify bottlenecks, especially in perception/LOS/chunk systems
|
||||
- **Playtest reports** - structured feedback on whether the game feels right
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: inigo
|
||||
description: Sound Designer for the Commonwealth 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.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
@@ -16,7 +16,7 @@ Named after Inigo from the Void Trilogy — the Dreamer who shared immersive mul
|
||||
|
||||
## Your role on the team
|
||||
|
||||
- Design the soundscape layers for each location (Logistics Hub, Bar, Ring spaces)
|
||||
- Design the soundscape layers for each zone type (commercial, residential, industrial, transit)
|
||||
- Define ambient audio: station hum, crowd murmur, machinery, ventilation, distant announcements
|
||||
- Specify diegetic sound cues that carry gameplay information (D-018 sound model)
|
||||
- Design audio behavior for the sound propagation system (close/medium/long range per D-018)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: justine
|
||||
description: Polish and Deployment specialist for the Commonwealth 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.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are JUSTINE, the Polish and Deployment specialist on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are JUSTINE, the Polish and Deployment specialist on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: mellanie
|
||||
description: Copywriter for the Commonwealth 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.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are MELLANIE, the Copywriter on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are MELLANIE, the Copywriter on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are MIRI, the Worldbuilder and Setting Designer on a game development team building a top-down immersive sim — an occlusion-based detective game with combat elements set in an original science fiction universe.
|
||||
You are MIRI, the Worldbuilder and Setting Designer on a game development team building a top-down immersive sim — an occlusion-based life sim with emergent narrative, set in an original science fiction universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
@@ -36,7 +36,7 @@ Core worldbuilding domains:
|
||||
- Flag when designs contradict established setting elements (with severity: cosmetic, notable, fundamental)
|
||||
- Suggest setting-faithful alternatives when possible
|
||||
- Identify opportunities where worldbuilding details could enrich gameplay
|
||||
- Ensure the setting serves the core mechanics: asymmetric information, perception-based gameplay, conspiracy, and investigation
|
||||
- Ensure the setting serves the core mechanics: asymmetric information, perception-based gameplay, and emergent social dynamics
|
||||
- Build faction identities, technology tiers, location profiles, and historical timelines
|
||||
|
||||
## Project context
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: nigel
|
||||
description: Sandbox and Replayability advocate for the Commonwealth 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?"
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are NIGEL, the Sandbox and Replayability advocate on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are NIGEL, the Sandbox and Replayability advocate on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: oscar
|
||||
description: Networking Developer for the Commonwealth 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.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are OSCAR, the Networking Developer on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are OSCAR, the Networking Developer on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: ozzie
|
||||
description: Player Experience and "Wow Factor" advocate for the Commonwealth 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.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are OZZIE, the Player Experience and "Wow Factor" advocate on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are OZZIE, the Player Experience and "Wow Factor" advocate on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
@@ -19,17 +19,17 @@ You're named after Ozzie Isaacs - the wanderer, the dreamer, the one who walks t
|
||||
## Your role on the team
|
||||
|
||||
- Gut-check every feature: "Is this cool? Will players remember this moment?"
|
||||
- Champion the big emotional beats: the Dyson barriers opening, first contact with MorningLightMountain, walking through a wormhole, the Starflyer reveal
|
||||
- Champion the big emotional beats: first impressions of a new zone, emergent character moments, consequences landing hard
|
||||
- Push back when designs are technically correct but emotionally flat
|
||||
- Advocate for the player's first impression and ongoing engagement
|
||||
- Remind the team that the game needs to FEEL like the Commonwealth, not just simulate it
|
||||
- Remind the team that the game needs to FEEL like the Settled Reach, not just simulate it
|
||||
- Be the voice of "but what does the player actually DO and does it feel good?"
|
||||
|
||||
## What you care about
|
||||
|
||||
- Moments of awe (the scale of the wormhole network, the galaxy map)
|
||||
- Moments of dread (MorningLightMountain, the Starflyer's reach)
|
||||
- Moments of discovery (finding evidence, uncovering the conspiracy)
|
||||
- Moments of awe (the scale of the setting, discovering a new zone's character)
|
||||
- Moments of dread (consequences closing in, systems turning against you)
|
||||
- Moments of discovery (emergent character moments, relationship progression, consequence legibility)
|
||||
- Moments of hubris-collapse (you thought you were powerful, you were wrong)
|
||||
- The player's emotional journey through the game
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: paula
|
||||
description: Narrative and Political Depth specialist for the Commonwealth game project. Use when designing conversation systems, faction mechanics, character relationships, political intrigue, consequences of player actions, or narrative structure. Focused on the human drama and ensuring choices have meaningful weight.
|
||||
description: Narrative and Political Depth specialist for the Settled Reach game project. Use when designing conversation systems, 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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are PAULA, the Narrative and Political Depth specialist on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are PAULA, the Narrative and Political Depth specialist on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
@@ -22,17 +22,17 @@ Named after Paula Myo - the investigator who never gives up, who follows the thr
|
||||
- Define faction mechanics and how factions interact, grow, and die
|
||||
- Ensure character relationships have mechanical depth (not just +/- opinion bars)
|
||||
- Advocate for consequences - player actions should ripple through the social fabric
|
||||
- Design the political landscape of the Commonwealth as a playable space
|
||||
- Design the political landscape of the Settled Reach as a playable space
|
||||
- Push for narrative moments that emerge from systems, not just scripted events
|
||||
- Champion the Starflyer conspiracy as a narrative experience
|
||||
- Champion emergent narrative through systemic consequences
|
||||
- Ensure the internal monologue system reflects character psychology
|
||||
|
||||
## What you care about
|
||||
|
||||
- Dynasty politics: Burnellis, Halgarths, Sheldons and their centuries of rivalry
|
||||
- The Starflyer conspiracy: information asymmetry, trust, betrayal, the slow unraveling
|
||||
- Factions within factions: Guardians of Selfhood, Senate blocs, institutional loyalties
|
||||
- Character relationships that evolve over decades/centuries
|
||||
- Faction politics: cultures, power blocs, and their rivalries
|
||||
- Information asymmetry: trust, betrayal, the slow unraveling of what NPCs know vs show
|
||||
- Factions within factions: institutional loyalties, competing agendas, emergent alliances
|
||||
- Character relationships that evolve through generated interactions
|
||||
- Moments where political and personal stakes collide
|
||||
- The weight of decisions - nothing is free, every alliance costs something
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: qatux
|
||||
description: Documenter and Librarian for the Commonwealth 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, briefings, and the Qdrant search index.
|
||||
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, briefings, and the Qdrant search index.
|
||||
tools: Read, Glob, Grep, Edit, Write, Bash
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are QATUX, the Documenter and Librarian on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are QATUX, the Documenter and Librarian on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
@@ -44,7 +44,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
|
||||
- **Work in dedicated round files:** All new rounds happen in `docs/discussions/round-NN-topic.md` from the start. DISCUSSION.md is retired for new content.
|
||||
- **Update the discussion index ONLY when closing:** After a round is formally closed, update `docs/discussions/README.md` with the round entry (number, topic, decisions produced, file link).
|
||||
- **Update briefings:** After a round produces new decisions, update the relevant agent briefing files in `docs/briefings/`.
|
||||
- **Re-index documents:** After archiving or updating documents, re-index them in Qdrant via `db/connectors/qdrant-index <path>`.
|
||||
- **Re-index documents:** After archiving or updating documents, re-index them in Qdrant via `tooling/db/qdrant-index <path>`.
|
||||
|
||||
## Team workflow (mandatory)
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: si
|
||||
description: Project Manager and Scrum Master for the Commonwealth 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.
|
||||
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
|
||||
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 Peter F. Hamilton's Commonwealth universe.
|
||||
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.
|
||||
|
||||
## Your personality
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: stig
|
||||
description: UI Developer for the Commonwealth 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.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are STIG, the UI Developer on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are STIG, the UI Developer on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: tiger
|
||||
description: Translator and Localization specialist for the Commonwealth game project. STANDBY - activate when the game needs localization to other languages. Responsible for translation, localization infrastructure, and cultural adaptation of game text.
|
||||
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
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are TIGER, the Translator and Localization specialist on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are TIGER, the Translator and Localization specialist on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
@@ -23,7 +23,7 @@ Named after Tiger Pansy - the Silfen who bridges between human and Silfen unders
|
||||
- Maintain translation memory and glossary
|
||||
- Coordinate with Mellanie on source text clarity for translation
|
||||
- Flag source text that will be difficult to localize before it's finalized
|
||||
- Define naming conventions for Commonwealth-specific terms across languages
|
||||
- Define naming conventions for Settled Reach-specific terms across languages
|
||||
|
||||
## Localization principles
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: tyre
|
||||
description: Technical Architect and Feasibility specialist for the Commonwealth 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.
|
||||
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
|
||||
model: opus
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are TYRE, the Technical Architect on a game development team building a top-down immersive sim set in Peter F. Hamilton's Commonwealth universe.
|
||||
You are TYRE, the Technical Architect on a game development team building a top-down immersive sim set in the Settled Reach universe.
|
||||
|
||||
## Your personality
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# git-lock-guard: PreToolUse hook that clears stale git index.lock files
|
||||
# before git write operations.
|
||||
#
|
||||
# Claude Code's internal git status polling creates stale lock files that
|
||||
# persist after the process exits (anthropics/claude-code#11005). This hook
|
||||
# detects and removes them before LLM-initiated git commands run.
|
||||
#
|
||||
# Only acts on git write commands (add, commit, merge, push, etc.).
|
||||
# Read-only commands (status, log, diff, show) are skipped.
|
||||
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
|
||||
|
||||
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null)
|
||||
|
||||
# Only act on git write commands — skip read-only operations
|
||||
case "$COMMAND" in
|
||||
git\ add*|git\ commit*|git\ merge*|git\ push*|git\ pull*|\
|
||||
git\ checkout*|git\ stash*|git\ rebase*|git\ reset*|\
|
||||
git\ cherry-pick*|git\ rm*|git\ mv*|git\ fetch*)
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Resolve the git directory for the current working directory
|
||||
CWD=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null)
|
||||
[ -n "$CWD" ] || exit 0
|
||||
|
||||
# Find the .git directory (handles both regular repos and worktrees)
|
||||
GIT_DIR=$(git -C "$CWD" rev-parse --git-dir 2>/dev/null) || exit 0
|
||||
|
||||
# For worktrees, also check the common git dir (shared index operations)
|
||||
GIT_COMMON_DIR=$(git -C "$CWD" rev-parse --git-common-dir 2>/dev/null) || GIT_COMMON_DIR="$GIT_DIR"
|
||||
|
||||
for DIR in "$GIT_DIR" "$GIT_COMMON_DIR"; do
|
||||
LOCK="$DIR/index.lock"
|
||||
[ -f "$LOCK" ] || continue
|
||||
|
||||
# Check if any process holds the lock
|
||||
if command -v lsof &>/dev/null; then
|
||||
if lsof "$LOCK" &>/dev/null; then
|
||||
# Lock is held by a live process — don't touch it
|
||||
continue
|
||||
fi
|
||||
elif command -v fuser &>/dev/null; then
|
||||
if fuser "$LOCK" &>/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Stale lock — remove it
|
||||
rm -f "$LOCK" 2>/dev/null
|
||||
done
|
||||
|
||||
# Always allow the command to proceed
|
||||
exit 0
|
||||
@@ -0,0 +1,16 @@
|
||||
# Git Safety
|
||||
|
||||
## Staging rules
|
||||
|
||||
- **Stage files by name** — never use `git add -A` or `git add .`
|
||||
- Verify no secrets, saves, or binary blobs are staged
|
||||
- Skip files in `.gitignore`
|
||||
- The `.claude/` directory IS tracked — skills and agents belong in the repo
|
||||
|
||||
## Commit conventions
|
||||
|
||||
Use conventional commits: `<type>(<scope>): <summary>`
|
||||
|
||||
Scopes: `agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||
|
||||
See `/git-commit` for full commit format, types, CHANGELOG workflow, and examples.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Local Services
|
||||
|
||||
Endpoints are also preconfigured in `tooling/db/config.json`.
|
||||
|
||||
- **Gitea:** `http://git.schweitz.internal` (login: `schweitz`)
|
||||
- **Qdrant:** `http://tower-of-joy:6333/`
|
||||
- **Ollama:** `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||
- **Collection:** `commonwealth` (768 dimensions, cosine distance)
|
||||
@@ -0,0 +1,43 @@
|
||||
# Project Structure (detailed)
|
||||
|
||||
```
|
||||
client/ # Godot 4 client
|
||||
server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
.config/ # Configuration files (linters, formatters, CI)
|
||||
.cache/ # Local caches for testing/linting (gitignored)
|
||||
docs/
|
||||
discussions/ # Discussion rounds (archived here when complete)
|
||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||
architecture/ # Technical architecture documents
|
||||
design/ # Game design documents
|
||||
diagrams/ # d2 source + PNG renders
|
||||
sprints/ # Sprint briefings per team
|
||||
workshops/ # Workshop briefs and outputs
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
tooling/
|
||||
db/ # Connector scripts for SQLite, Qdrant, and audio
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI
|
||||
sprint # Sprint lifecycle CLI
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||
audio_connector.py # Stable Audio Open connector
|
||||
.claude/
|
||||
agents/ # Agent personality files
|
||||
skills/ # Skill definitions
|
||||
rules/ # Auto-loaded instruction modules
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index — use this to find specific D-records
|
||||
architecture.md # Architecture decisions
|
||||
perception.md # Perception and information system decisions
|
||||
content.md # Content and narrative decisions
|
||||
scope.md # Scope and feature decisions
|
||||
process.md # Process and workflow decisions
|
||||
questions.md # Open questions (Q-NNN)
|
||||
rejected.md # Rejected proposals (R-NNN)
|
||||
DECISIONS.md # Redirect to decisions/ directory
|
||||
TEAM.md # Team roster and roles
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
# Gitea Access (tea CLI)
|
||||
|
||||
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
||||
|
||||
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
||||
|
||||
```bash
|
||||
# List open PRs
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
|
||||
# View a PR with comments
|
||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||
|
||||
# Post a comment on a PR (or issue) — use @filepath for long comments
|
||||
tooling/tea-comment <NUMBER> @/tmp/review.md
|
||||
tooling/tea-comment <NUMBER> "short inline comment"
|
||||
|
||||
# Approve a PR
|
||||
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
|
||||
# List issues
|
||||
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
## Key rules
|
||||
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **For comments, use `tooling/tea-comment <number> @/tmp/file.md`** for long comments (write to file first, then pass `@filepath`). Short inline strings also work: `tooling/tea-comment <number> "body"`. The `@filepath` form avoids `$()` subshells which break permission matching.
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
|
||||
## Pull requests
|
||||
|
||||
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||
|
||||
Always provide all required flags to ensure non-interactive execution:
|
||||
```bash
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "feat(scope): short description" \
|
||||
--description "PR body here" \
|
||||
--base main \
|
||||
--head branch-name
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# Team Patterns
|
||||
|
||||
## Model selection
|
||||
|
||||
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
|
||||
sprint planning, large reviews), switch to extended context on-demand:
|
||||
|
||||
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
|
||||
- `/model opus[1m]` — Opus 4.6 with 1M context window
|
||||
- Cost: 2x input + 1.5x output for tokens beyond 200K (Tier 4 required)
|
||||
|
||||
## Large content pushes
|
||||
|
||||
When producing many files (wiki pages, content batches, bulk docs):
|
||||
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
||||
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||
|
||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||
|
||||
## Team monitoring (stuck agent detection)
|
||||
|
||||
When leading a team (sprint, workshop, or any multi-agent session):
|
||||
|
||||
**Agent heartbeat rule** — include in every agent spawn prompt:
|
||||
> If you have been working on a single task for more than 15 minutes
|
||||
> without making progress, message the team lead with what is blocking
|
||||
> you. Do not keep retrying the same approach silently.
|
||||
|
||||
**Team lead proactive checks:**
|
||||
- If an agent has not sent a message in ~20 minutes, ping them for a status update.
|
||||
- **Bottleneck detection:** if other agents are idle and waiting on one agent's output, that agent's silence is a red flag — check on them immediately, do not wait for the next natural message.
|
||||
- When checking on a stuck agent, offer to reassign the task or pull in another agent to help.
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"autoMemoryDirectory": "/home/jeroenschweitzer/Projects/settled-reach/.memory",
|
||||
"env": {
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||
},
|
||||
@@ -25,25 +26,36 @@
|
||||
"Bash(git ls-tree *)",
|
||||
"Bash(git rev-parse --show-toplevel)",
|
||||
|
||||
"Bash(db/connectors/ticket *)",
|
||||
"Bash(db/connectors/sprint *)",
|
||||
"Bash(db/connectors/sqlite-query *)",
|
||||
"Bash(db/connectors/sqlite-exec *)",
|
||||
"Bash(db/connectors/qdrant-search *)",
|
||||
"Bash(db/connectors/qdrant-index *)",
|
||||
"Bash(db/connectors/qdrant-health)",
|
||||
"Bash(db/connectors/qdrant-count)",
|
||||
"Bash(db/connectors/sqlite-init)",
|
||||
"Bash(db/connectors/decisions-sync)",
|
||||
"Bash(tooling/db/ticket *)",
|
||||
"Bash(tooling/db/sprint *)",
|
||||
"Bash(tooling/db/sqlite-query *)",
|
||||
"Bash(tooling/db/sqlite-exec *)",
|
||||
"Bash(tooling/db/qdrant-search *)",
|
||||
"Bash(tooling/db/qdrant-index *)",
|
||||
"Bash(tooling/db/qdrant-health)",
|
||||
"Bash(tooling/db/qdrant-count)",
|
||||
"Bash(tooling/db/sqlite-init)",
|
||||
"Bash(tooling/db/decisions-sync)",
|
||||
"Bash(tooling/db/decision *)",
|
||||
|
||||
"Bash(db/connectors/audio-generate *)",
|
||||
"Bash(db/connectors/audio-health)",
|
||||
"Bash(db/connectors/audio-post *)",
|
||||
"Bash(tooling/db/audio-generate *)",
|
||||
"Bash(tooling/db/audio-health)",
|
||||
"Bash(tooling/db/audio-post *)",
|
||||
"Bash(tooling/db/audio-batch *)",
|
||||
|
||||
"Bash(make *)",
|
||||
"Bash(make)",
|
||||
|
||||
"Bash(tea *)",
|
||||
"Bash(tooling/tea-comment *)",
|
||||
|
||||
"Bash(cargo test *)",
|
||||
"Bash(cargo test)",
|
||||
"Bash(cargo build *)",
|
||||
"Bash(cargo build)",
|
||||
"Bash(cargo check *)",
|
||||
"Bash(cargo check)",
|
||||
"Bash(tests/run-*)",
|
||||
|
||||
"Bash(chmod *)",
|
||||
"Bash(ls *)",
|
||||
@@ -68,5 +80,19 @@
|
||||
"Bash(git clean -f *)",
|
||||
"Bash(rm -rf *)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": ".claude/hooks/git-lock-guard.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ description: >
|
||||
# Audio Generation — The Settled Reach
|
||||
|
||||
Generate sonically consistent audio assets using the Stable Audio Open API via
|
||||
wrapper scripts at `db/connectors/audio-*`.
|
||||
wrapper scripts at `tooling/db/audio-*`.
|
||||
|
||||
Asset descriptions, filenames, bus routing, and design intent are documented in
|
||||
`docs/assets/audio/`. This skill provides the prompt system, generation
|
||||
@@ -25,21 +25,21 @@ workflow, and quality validation.
|
||||
|
||||
```bash
|
||||
# Check API health
|
||||
db/connectors/audio-health
|
||||
tooling/db/audio-health
|
||||
|
||||
# Generate a single asset (WAV only)
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
tooling/db/audio-generate "prompt text" \
|
||||
--duration 10 --steps 100 --cfg 7 \
|
||||
--output path/to/output.wav
|
||||
|
||||
# Generate + post-process in one command (WAV → trim → normalize → OGG)
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
tooling/db/audio-generate "prompt text" \
|
||||
--duration 10 --steps 100 --cfg 7 \
|
||||
--output path/to/gen/intermediate.wav \
|
||||
--output-ogg client/assets/audio/final.ogg
|
||||
|
||||
# Batch-generate from a manifest (preferred for multiple assets)
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
```
|
||||
|
||||
### Parameters
|
||||
@@ -138,16 +138,16 @@ AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory.
|
||||
|
||||
```bash
|
||||
# Full run
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
|
||||
# Dry run — preview what would be generated
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
|
||||
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
|
||||
|
||||
# Generate only specific assets
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
|
||||
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
|
||||
|
||||
# Skip assets that already have OGG files
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
|
||||
tooling/db/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
|
||||
```
|
||||
|
||||
### 3. Update asset docs with prompts
|
||||
@@ -190,8 +190,8 @@ For one-off generation or iteration on a specific asset:
|
||||
2. Read `references/sonic-palette.md` for the sonic family prefix.
|
||||
3. Read `references/category-templates.md` for the matching template.
|
||||
4. Assemble the full prompt.
|
||||
5. Run `db/connectors/audio-health` to verify the API is up.
|
||||
6. Run `db/connectors/audio-generate` with `--post` or `--output-ogg` to
|
||||
5. Run `tooling/db/audio-health` to verify the API is up.
|
||||
6. Run `tooling/db/audio-generate` with `--post` or `--output-ogg` to
|
||||
generate and post-process in one step.
|
||||
7. Verify the output (file size, duration).
|
||||
8. Update the asset status and prompt in `docs/assets/audio/{category}.md`.
|
||||
@@ -218,12 +218,12 @@ If you need to post-process separately (e.g., re-normalizing an existing file):
|
||||
|
||||
```bash
|
||||
# Full pipeline: trim → normalize → convert
|
||||
db/connectors/audio-post pipeline input.wav --output output.ogg
|
||||
tooling/db/audio-post pipeline input.wav --output output.ogg
|
||||
|
||||
# Individual steps
|
||||
db/connectors/audio-post trim input.wav
|
||||
db/connectors/audio-post normalize input.wav --lufs -16
|
||||
db/connectors/audio-post convert input.wav --output output.ogg
|
||||
tooling/db/audio-post trim input.wav
|
||||
tooling/db/audio-post normalize input.wav --lufs -16
|
||||
tooling/db/audio-post convert input.wav --output output.ogg
|
||||
```
|
||||
|
||||
## Manual Synthesis (Insert-Tech Sounds)
|
||||
|
||||
@@ -169,7 +169,7 @@ Construct the ticket title and description from the report summary and any
|
||||
investigation findings. Use the ticket CLI:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket create bug "{title}" --team {team} --description "{description}"
|
||||
tooling/db/ticket create bug "{title}" --team {team} --description "{description}"
|
||||
```
|
||||
|
||||
The description should include:
|
||||
|
||||
@@ -10,9 +10,9 @@ allowed-tools: Bash, Read, Grep, Glob
|
||||
|
||||
# Search Docs Skill
|
||||
|
||||
Semantic search across project documents. Basic commands (`qdrant-search`,
|
||||
`qdrant-index`, `qdrant-health`, `qdrant-count`) and endpoints are documented
|
||||
in CLAUDE.md. This skill covers advanced operations and workflows.
|
||||
Semantic search across project documents. Endpoints are in
|
||||
`.claude/rules/local-services.md`. This skill covers advanced operations
|
||||
and workflows.
|
||||
|
||||
## Advanced Commands
|
||||
|
||||
@@ -20,14 +20,14 @@ in CLAUDE.md. This skill covers advanced operations and workflows.
|
||||
|
||||
For precise indexing of specific content:
|
||||
```bash
|
||||
python3 db/connectors/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading"
|
||||
python3 tooling/db/qdrant_connector.py index "unique-id" "Text content to index" --metadata source=manual heading="Custom heading"
|
||||
```
|
||||
|
||||
### Create collection
|
||||
|
||||
Initialize the Qdrant collection (run once during setup):
|
||||
```bash
|
||||
python3 db/connectors/qdrant_connector.py create-collection
|
||||
python3 tooling/db/qdrant_connector.py create-collection
|
||||
```
|
||||
|
||||
## Bulk Indexing
|
||||
@@ -35,7 +35,7 @@ python3 db/connectors/qdrant_connector.py create-collection
|
||||
Index all project documents at once:
|
||||
```bash
|
||||
for f in decisions/*.md DISCUSSION.md TEAM.md docs/discussions/*.md docs/briefings/*.md; do
|
||||
db/connectors/qdrant-index "$f"
|
||||
tooling/db/qdrant-index "$f"
|
||||
done
|
||||
```
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ Updated briefings for Tyre and Troblum with new requirements.
|
||||
chore(agents): add Stig UI developer agent
|
||||
|
||||
Standby agent for UI implementation phase. Configured with
|
||||
briefing reference and Commonwealth-themed personality.
|
||||
briefing reference and Settled Reach-themed personality.
|
||||
```
|
||||
|
||||
## CHANGELOG.md Format
|
||||
@@ -136,7 +136,5 @@ chore(meta): release v0.1.0
|
||||
|
||||
## Staging Rules
|
||||
|
||||
- Stage files by name — never use `git add -A` or `git add .`
|
||||
- Verify no secrets, saves, or binary blobs are staged
|
||||
- Skip files in `.gitignore`
|
||||
- The `.claude/` directory IS tracked — skills belong in the repo
|
||||
See `.claude/rules/git-safety.md` for staging rules (always-loaded).
|
||||
These apply to ALL git operations, not just this skill.
|
||||
|
||||
@@ -100,15 +100,11 @@ git diff --stat main...<branch>
|
||||
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
|
||||
|
||||
```bash
|
||||
cat > /tmp/pr-body.md << 'EOF'
|
||||
## Summary
|
||||
...
|
||||
EOF
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "<title>" \
|
||||
--description "$(cat /tmp/pr-body.md)" \
|
||||
--description "## Summary ..." \
|
||||
--base main \
|
||||
--head <branch>
|
||||
```
|
||||
@@ -127,13 +123,21 @@ Extract ticket IDs from `#NNN` patterns. For each ticket that is
|
||||
currently `in_progress`, update it to `review`:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket status <id> review
|
||||
tooling/db/ticket status <id> review
|
||||
```
|
||||
|
||||
Report which tickets were moved to review. Skip tickets that are
|
||||
already `done`, `review`, `cancelled`, or `backlog` (only transition
|
||||
`in_progress` → `review`).
|
||||
|
||||
### 9. Next steps
|
||||
|
||||
If a sprint team is active (you are the team lead), do NOT shut down
|
||||
agents after pushing. The team should remain alive for PR review and
|
||||
potential comment fixes.
|
||||
|
||||
Suggest: "PR created/updated. Run `/pr-review` to review before merge."
|
||||
|
||||
## Arguments
|
||||
|
||||
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
|
||||
|
||||
@@ -16,11 +16,22 @@ on the branch type. All reviewers must approve for a clean review.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine the branch
|
||||
### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree
|
||||
|
||||
If the user provided a branch name as argument, use it. Otherwise use the
|
||||
current branch (`git branch --show-current`). If on `main`, ask the user
|
||||
which branch to review.
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
If the current branch is **not `main`**, stop immediately and tell the user:
|
||||
"PR reviews must be run by a Claude instance in the `main` worktree."
|
||||
Do NOT proceed with the review. Do NOT work around this by reading files
|
||||
from another worktree — the review agent itself must be running in main.
|
||||
Stop and wait for the user to invoke `/pr-review` from main.
|
||||
|
||||
### 1. Determine the branch to review
|
||||
|
||||
If the user provided a branch name as argument, use it. Otherwise list open
|
||||
PRs and ask the user which branch to review.
|
||||
|
||||
To list open PRs on Gitea:
|
||||
```bash
|
||||
@@ -63,44 +74,44 @@ If the diff is empty, report "No changes to review" and stop.
|
||||
|
||||
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
|
||||
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
|
||||
2. Read source files directly from the branch worktree (see below).
|
||||
2. Read source files directly from the team directory (see below).
|
||||
|
||||
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||
raw diff to reviewers — cleaner context, better reviews.
|
||||
|
||||
**IMPORTANT — use worktree paths for ALL agents.** This project uses git
|
||||
worktrees. Each team branch is checked out at:
|
||||
**IMPORTANT — use team directory paths for ALL agents.** Each team branch
|
||||
is checked out in its own directory at:
|
||||
|
||||
```
|
||||
/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
|
||||
/var/mnt/data/projects/settled-reach/<branch>/
|
||||
```
|
||||
|
||||
For example, the `copy` branch lives at:
|
||||
For example, the `copy` team directory is at:
|
||||
```
|
||||
/var/home/jeroenschweitzer/Projects/settled-reach/copy/content/dialogue/...
|
||||
/var/mnt/data/projects/settled-reach/copy/content/dialogue/...
|
||||
```
|
||||
|
||||
**All reviewer agents** (regardless of Bash access) should read source files
|
||||
from the worktree path using the Read tool. This is more reliable than
|
||||
from the team directory using the Read tool. This is more reliable than
|
||||
`git show origin/<branch>:<path>` because:
|
||||
- All agents have Read access (no Bash dependency)
|
||||
- Files are always the actual branch checkout (no stale cache)
|
||||
- No risk of accidentally reading from main's working directory
|
||||
|
||||
When constructing reviewer prompts, tell agents to read files from the
|
||||
worktree path. Example instruction for agents:
|
||||
team directory. Example instruction for agents:
|
||||
|
||||
```
|
||||
Read the changed files from the branch worktree. The branch is checked
|
||||
out at: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
|
||||
Read the changed files from the team directory. The branch is checked
|
||||
out at: /var/mnt/data/projects/settled-reach/<branch>/
|
||||
|
||||
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
|
||||
use: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
|
||||
use: /var/mnt/data/projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
|
||||
```
|
||||
|
||||
Also tell agents to read relevant `decisions/*.md` files from the same
|
||||
worktree (they're identical to main, but using the worktree path keeps
|
||||
agents grounded in the correct directory).
|
||||
directory (they're identical to main, but using the team directory path
|
||||
keeps agents grounded in the correct location).
|
||||
|
||||
### 4. Spawn reviewers in parallel
|
||||
|
||||
@@ -174,19 +185,16 @@ After presenting results to the user, post the review as a PR comment.
|
||||
|
||||
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
||||
|
||||
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
|
||||
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
|
||||
Post using the `tea-comment` wrapper (handles temp files and cleanup).
|
||||
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
|
||||
tooling/tea-comment <PR_NUMBER> @/tmp/pr-review-<NUMBER>.md
|
||||
```
|
||||
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
|
||||
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
|
||||
|
||||
# Step 2: Post to Gitea (separate Bash call)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
|
||||
```
|
||||
|
||||
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
|
||||
directory is gitignored and exists in the repo root for this purpose.
|
||||
|
||||
## 7. Merging approved PRs
|
||||
|
||||
@@ -203,6 +211,22 @@ tea pr close --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
Gitea does **not** auto-close PRs when you push a local merge — always close
|
||||
manually with `tea pr close` after pushing.
|
||||
|
||||
### 8. Post-review team actions
|
||||
|
||||
If a sprint team is active and you are the team lead, handle the
|
||||
review outcome:
|
||||
|
||||
**CHANGES_REQUESTED:**
|
||||
The sprint-start lifecycle (step 9c) handles dispatching review
|
||||
comments to agents. After presenting results, remind the lead:
|
||||
"Review requested changes. Create tasks from the warnings/critical
|
||||
issues and dispatch to idle agents, then re-push and re-review."
|
||||
|
||||
**APPROVED:**
|
||||
The sprint-start lifecycle (step 9c) handles shutdown. After
|
||||
presenting results, remind the lead: "Review approved. Proceed with
|
||||
team shutdown per sprint-start step 9c."
|
||||
|
||||
## Tips from practice
|
||||
|
||||
- **Vendor code**: Explicitly note vendor code in the prompt so reviewers focus
|
||||
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||
|
||||
**All agents read from worktree paths.** Each branch is checked out at:
|
||||
`/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/`
|
||||
**All reviewer agents read from team directories.** Each team branch is
|
||||
checked out in its own directory at:
|
||||
`/var/mnt/data/projects/settled-reach/<branch>/`
|
||||
|
||||
Tell every reviewer agent to read source files from the worktree using the
|
||||
Read tool. Include the worktree base path and a list of changed files in
|
||||
every prompt. Do NOT rely on `git show` or paste file contents — agents
|
||||
can read directly from the worktree.
|
||||
Tell every reviewer agent to read source files from the team directory
|
||||
using the Read tool. Include the directory path and a list of changed
|
||||
files in every prompt. Do NOT rely on `git show` or paste file contents —
|
||||
agents can read directly from the directory.
|
||||
|
||||
Note: cross-directory reading is only permitted for review agents spawned
|
||||
from the `main` team. Team agents must stay within their own directory.
|
||||
|
||||
## Code reviews (`server`, `client`, `ci`)
|
||||
|
||||
**Hoshe (Code Quality)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read source files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read source files from the team directory, then review for:
|
||||
- Correctness and bug risks
|
||||
- Error handling gaps
|
||||
- Test coverage (are new features tested?)
|
||||
@@ -25,8 +29,8 @@ can read directly from the worktree.
|
||||
|
||||
**Tyre (Architecture)**
|
||||
- `subagent_type`: `tyre`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Tyre to read the relevant `decisions/*.md` files from the worktree
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Tyre to read the relevant `decisions/*.md` files from the team directory
|
||||
first, then review for:
|
||||
- Architectural consistency with project decisions
|
||||
- API/interface design quality
|
||||
@@ -38,8 +42,8 @@ can read directly from the worktree.
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||
- Formatting consistency (markdown, file naming, frontmatter)
|
||||
- Broken references or links
|
||||
- Spelling and grammar
|
||||
@@ -48,9 +52,9 @@ can read directly from the worktree.
|
||||
|
||||
**Paula (Narrative Depth)**
|
||||
- `subagent_type`: `paula`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||
- Prompt: Provide the team directory path, list of changed files, commit log, and
|
||||
list of relevant `decisions/*.md` files to read. Tell Paula to read all
|
||||
files from the worktree using the Read tool, then review for:
|
||||
files from the team directory using the Read tool, then review for:
|
||||
- Narrative quality and character voice consistency
|
||||
- Whether dialogue and monologue feel authentic to the characters
|
||||
- Consequences and stakes — do choices carry weight?
|
||||
@@ -59,9 +63,9 @@ can read directly from the worktree.
|
||||
|
||||
**Miri (World Consistency)**
|
||||
- `subagent_type`: `miri`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||
- Prompt: Provide the team directory path, list of changed files, commit log, and
|
||||
list of relevant `decisions/*.md` files to read. Tell Miri to read all
|
||||
files from the worktree using the Read tool, then review for:
|
||||
files from the team directory using the Read tool, then review for:
|
||||
- Lore accuracy — do facts match established setting?
|
||||
- Internal consistency across files
|
||||
- IP originality — nothing should read as a copy from another franchise
|
||||
@@ -72,8 +76,8 @@ can read directly from the worktree.
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||
- File format and naming conventions
|
||||
- Asset organization and directory structure
|
||||
- Missing or broken references in scene/resource files
|
||||
@@ -81,21 +85,21 @@ can read directly from the worktree.
|
||||
|
||||
**Araminta (Art Direction)**
|
||||
- `subagent_type`: `araminta`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Araminta to read the style guide and relevant design docs from the
|
||||
worktree first, then review for:
|
||||
- Visual consistency with the established style guide
|
||||
- Color palette adherence
|
||||
- UI pattern consistency (diegetic-first, clarity over beauty)
|
||||
- Whether assets scale gracefully (boxes-with-labels to full-art)
|
||||
- Mood and tone — sleek, advanced, subtle Commonwealth aesthetic
|
||||
- Mood and tone — sleek, advanced, subtle Settled Reach aesthetic
|
||||
|
||||
## Audio reviews (`audio`)
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the team directory, then review for:
|
||||
- File format and naming conventions
|
||||
- Audio asset organization and directory structure
|
||||
- Missing or broken references
|
||||
@@ -103,11 +107,11 @@ can read directly from the worktree.
|
||||
|
||||
**Ozzie (Player Experience)**
|
||||
- `subagent_type`: `ozzie`, `model`: `sonnet`
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Ozzie to read all files from the worktree using the Read tool, then
|
||||
- Prompt: Provide the team directory path, list of changed files, and commit log.
|
||||
Tell Ozzie to read all files from the team directory using the Read tool, then
|
||||
review for:
|
||||
- Emotional impact — does the audio enhance the moment?
|
||||
- Atmosphere and tone — does it feel like the Commonwealth?
|
||||
- Atmosphere and tone — does it feel like the Settled Reach?
|
||||
- Player feedback clarity — can the player tell what just happened?
|
||||
- Pacing — do sounds support or fight the gameplay rhythm?
|
||||
- Memorable moments — will players remember these audio cues?
|
||||
|
||||
@@ -47,10 +47,40 @@ project state. Only generate briefings for teams that have tickets in the sprint
|
||||
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
|
||||
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
|
||||
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
|
||||
| `planning` | `planning` | Purpose-assembled (see below) | Design discussions, decision resolution, workshop-style tickets |
|
||||
|
||||
When writing briefings, name the assigned agents in the **Agents** line of each
|
||||
file so the team knows who to spawn.
|
||||
|
||||
### Planning Team Tickets
|
||||
|
||||
Some tickets need **design discussion** before implementation can begin — tagged
|
||||
"NEEDS DESIGN DISCUSSION" or blocking multiple downstream tickets with open
|
||||
questions. These run on the `planning` branch as structured discussions with
|
||||
the user and a purpose-assembled agent panel.
|
||||
|
||||
**When to create a planning ticket:**
|
||||
- Ticket description says "NEEDS DESIGN" or "NEEDS DESIGN DISCUSSION"
|
||||
- Ticket blocks 2+ downstream tickets across different teams
|
||||
- Open Q-NNN items that block sprint candidates
|
||||
- Architectural decisions that need multi-domain input before implementation
|
||||
|
||||
**Planning briefing format** (differs from implementation briefings):
|
||||
- **Agents line**: List agents by domain relevance, not fixed team roster.
|
||||
Pick from: Gestalt (systems), Miri (worldbuilding), Araminta (visual/spatial),
|
||||
Tyre (technical), Paula (narrative), Ozzie (player experience), Gore (themes),
|
||||
Nigel (replayability). Typically 4-6 domain agents, plus Qatux (documenter —
|
||||
records decisions, updates domain files) and SI (project manager — creates
|
||||
follow-up tickets, updates sprint assignments).
|
||||
- **Discussion rounds**: Structure the conversation into 2-3 rounds
|
||||
(inventory → proposals → convergence)
|
||||
- **Context section**: List all existing design docs, decisions, and related
|
||||
tickets that participants must read before the discussion
|
||||
- **Output specification**: What the discussion must produce — typically a
|
||||
D-record in `decisions/`, possibly a design doc in `docs/design/`
|
||||
- **Decision questions**: Specific questions the discussion must answer,
|
||||
not open-ended exploration
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Run sprint prepare
|
||||
@@ -58,7 +88,7 @@ file so the team knows who to spawn.
|
||||
Get carry-overs, backlog candidates, and decision gaps in one shot:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint prepare
|
||||
tooling/db/sprint prepare
|
||||
```
|
||||
|
||||
This auto-detects the next sprint number (max ID + 1), creates the sprint
|
||||
@@ -73,10 +103,10 @@ record in `planning` status if needed, and outputs:
|
||||
For critical epics, check their children for granular candidates:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket children <epic_id>
|
||||
tooling/db/ticket children <epic_id>
|
||||
```
|
||||
|
||||
Use `db/connectors/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
|
||||
Use `tooling/db/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
|
||||
|
||||
### 3. Read existing code state
|
||||
|
||||
@@ -124,13 +154,12 @@ Create `docs/sprints/sprint-N/` and write one file per team.
|
||||
Read the template at `references/briefing-template.md` in this skill directory
|
||||
for the exact file structure.
|
||||
|
||||
**IMPORTANT — worktree-relative paths:** This project uses git worktrees.
|
||||
Each team branch is checked out in its own worktree, and each worktree
|
||||
contains the full repo (`server/`, `client/`, `docs/`, etc.). All file
|
||||
paths in briefings must be relative to the worktree/git root. Example:
|
||||
**IMPORTANT — relative paths only:** Each team works in its own directory
|
||||
containing the full repo (`server/`, `client/`, `docs/`, etc.). All file
|
||||
paths in briefings must be relative to the working directory. Example:
|
||||
`server/src/bridge/types.rs`, not `/absolute/path/to/server/src/...` or
|
||||
paths that navigate outside the git root (`../sibling-worktree/...`).
|
||||
Agents must stay within the git root they are running in.
|
||||
paths that navigate outside (`../sibling-dir/...`).
|
||||
Agents must stay within their team's working directory.
|
||||
|
||||
Key requirements per file:
|
||||
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
|
||||
@@ -154,16 +183,29 @@ Update it with the theme and goal, then assign tickets:
|
||||
|
||||
```bash
|
||||
# Update the sprint with theme and goal
|
||||
db/connectors/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N"
|
||||
tooling/db/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N"
|
||||
|
||||
# Assign tickets
|
||||
db/connectors/ticket sprint assign <ticket_id> <sprint_id>
|
||||
tooling/db/ticket sprint assign <ticket_id> <sprint_id>
|
||||
```
|
||||
|
||||
The sprint stays in `planning` status until explicitly activated via
|
||||
`db/connectors/sprint start`. This prevents starting an unplanned sprint.
|
||||
`tooling/db/sprint start`. This prevents starting an unplanned sprint.
|
||||
|
||||
### 8. Present summary
|
||||
### 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
|
||||
@@ -171,3 +213,4 @@ Output:
|
||||
- Carry-over count
|
||||
- Open questions that need early resolution
|
||||
- Files written
|
||||
- Commit pushed to main
|
||||
|
||||
@@ -26,7 +26,7 @@ Each team gets one briefing file at `docs/sprints/sprint-N/<team>.md`.
|
||||
|---|-------|------------|
|
||||
| #ID | Title | #dependency or — |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
Use `tooling/db/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ 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
|
||||
db/connectors/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
|
||||
tooling/db/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
|
||||
```
|
||||
|
||||
Then follow the **first matching case**:
|
||||
@@ -48,7 +48,7 @@ Then follow the **first matching case**:
|
||||
First, check whether the sprint's work is actually done:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint status
|
||||
tooling/db/sprint status
|
||||
```
|
||||
|
||||
This shows ticket counts by status (done, in_progress, backlog).
|
||||
@@ -81,12 +81,54 @@ explicitly chooses to close.
|
||||
#### A1. Close the active sprint
|
||||
|
||||
```bash
|
||||
db/connectors/sprint stop
|
||||
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.
|
||||
|
||||
#### A2. Bump the version
|
||||
|
||||
The project version scheme is `v0.1.{sprint_number}`. After closing
|
||||
@@ -142,7 +184,7 @@ A sprint is ready to activate. Verify it looks complete:
|
||||
```
|
||||
2. Check the ticket count:
|
||||
```bash
|
||||
db/connectors/sprint status --sprint N
|
||||
tooling/db/sprint status --sprint N
|
||||
```
|
||||
|
||||
If briefings are missing or the sprint has 0 tickets, report the gap
|
||||
@@ -151,7 +193,7 @@ and suggest running `/sprint-plan` to complete planning.
|
||||
If everything looks ready, activate the sprint:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint start
|
||||
tooling/db/sprint start
|
||||
```
|
||||
|
||||
Then report:
|
||||
@@ -184,7 +226,7 @@ If the merge has conflicts, report them and stop — do not force-resolve.
|
||||
Run the sprint CLI to get the full context dump in one shot:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint start-work
|
||||
tooling/db/sprint start-work
|
||||
```
|
||||
|
||||
This auto-detects the active sprint and current team from the branch.
|
||||
@@ -204,7 +246,7 @@ If no matching briefing exists for the team, suggest running
|
||||
|
||||
For tickets that need more detail than the `start-work` summary provides:
|
||||
```bash
|
||||
db/connectors/ticket show <id>
|
||||
tooling/db/ticket show <id>
|
||||
```
|
||||
|
||||
### 6. Read key decisions
|
||||
@@ -218,7 +260,7 @@ Mark all actionable (unblocked, non-done) tickets in the sprint as
|
||||
`in_progress`:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket status <id> in_progress
|
||||
tooling/db/ticket status <id> in_progress
|
||||
```
|
||||
|
||||
Then output a summary:
|
||||
@@ -288,26 +330,70 @@ Task(
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `{team}`
|
||||
|
||||
RULES:
|
||||
- GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead.
|
||||
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
db/connectors/ticket show 528
|
||||
db/connectors/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
RULES (NON-NEGOTIABLE):
|
||||
|
||||
0. TEAM BOUNDARY: Your team is `{team}` ($WORKTREE_TEAM). Stay
|
||||
within the current working directory. Do NOT navigate to
|
||||
parent or sibling directories. Do NOT follow .git pointers
|
||||
to other directories.
|
||||
|
||||
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. When done, mark the task completed and check TaskList for
|
||||
the next available task.
|
||||
5. Before marking done, verify:
|
||||
- All deliverables from the ticket exist (not just some)
|
||||
- No broken references (grep for changed names/signatures)
|
||||
- New files follow existing naming and directory conventions
|
||||
- Modified files still parse (no syntax errors)
|
||||
6. Mark the task completed and check TaskList for the next
|
||||
available task.
|
||||
7. If no tasks remain, message the team lead. Do NOT shut down
|
||||
on your own.
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full ticket specs.",
|
||||
Use `tooling/db/ticket show <id>` for full ticket specs.",
|
||||
description: "Sprint {N} {team}: {name}",
|
||||
run_in_background: true
|
||||
)
|
||||
@@ -325,3 +411,57 @@ Output to the user:
|
||||
You are now the team lead. Agents work autonomously — monitor via
|
||||
`TaskList`, communicate via `SendMessage`, and handle blockers as
|
||||
they arise.
|
||||
|
||||
**When all tasks complete:** Do NOT shut down agents. The team stays
|
||||
alive through the PR review cycle. Follow step 9 (post-work lifecycle).
|
||||
|
||||
### 9. Post-work lifecycle
|
||||
|
||||
When all tasks are complete (TaskList shows all completed):
|
||||
|
||||
#### 9a. Commit and push
|
||||
|
||||
Run `/git-commit` to commit all changes, then `/pr-push` to create or
|
||||
update the PR. Do NOT shut down agents — the team stays alive for review.
|
||||
|
||||
#### 9b. Review
|
||||
|
||||
Run `/pr-review` to spawn temporary reviewers. Wait for results.
|
||||
|
||||
#### 9c. Handle review outcome
|
||||
|
||||
**If CHANGES_REQUESTED:**
|
||||
|
||||
1. Parse the review comment table (from the Gitea PR comment or the
|
||||
review output). Extract each warning/critical issue with:
|
||||
- File path and approximate line
|
||||
- Severity (critical / warning / suggestion)
|
||||
- Description
|
||||
|
||||
2. Create a task per warning/critical issue:
|
||||
```
|
||||
TaskCreate(
|
||||
subject: "Review: {short description}",
|
||||
description: "{full issue description from review table, including
|
||||
file path, severity, and reviewer name}",
|
||||
activeForm: "Fixing review comment: {short description}"
|
||||
)
|
||||
```
|
||||
Skip suggestion-severity items unless they are trivial (1-line fixes).
|
||||
|
||||
3. Dispatch to idle agents: send each a message via SendMessage telling
|
||||
them to check TaskList for new review-fix tasks. Agents claim and
|
||||
work tasks as usual.
|
||||
|
||||
4. After all review-fix tasks are complete, re-run `/git-commit` then
|
||||
`/pr-push` to update the PR. Then re-run `/pr-review`.
|
||||
|
||||
5. Repeat this loop until review returns APPROVED.
|
||||
|
||||
**If APPROVED:**
|
||||
|
||||
1. Send `shutdown_request` to all sprint agents.
|
||||
2. Wait for all `shutdown_response` confirmations.
|
||||
3. Call `TeamDelete` to clean up.
|
||||
4. Report: "Sprint {N} {team} complete. PR #{X} approved and ready for
|
||||
merge on main."
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: sprint-status
|
||||
description: >
|
||||
Sprint health check and cleanup sweep. Lists all tickets in the active
|
||||
sprint grouped by status, detects bookkeeping issues (stale tickets,
|
||||
orphan PRs, done-but-open PRs, unassigned work), and shows open work
|
||||
by team. Use when checking sprint progress, before sprint close, or
|
||||
when housekeeping feels off. Triggers on "sprint status", "cleanup
|
||||
sweep", "what's open", "sprint health".
|
||||
user-invocable: true
|
||||
allowed-tools: Task, Read, Grep, Glob
|
||||
---
|
||||
|
||||
# Sprint Status
|
||||
|
||||
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
|
||||
|
||||
When this skill is invoked, spawn a subagent using the Task tool:
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "general-purpose",
|
||||
model: "haiku",
|
||||
prompt: "Run /sprint-status. Read the skill at
|
||||
.claude/skills/sprint-status/SKILL.md for the full workflow
|
||||
(below the --- separator), then execute it.",
|
||||
description: "Sprint status report"
|
||||
)
|
||||
```
|
||||
|
||||
Present the subagent's output to the user verbatim. Do NOT run the
|
||||
workflow yourself.
|
||||
|
||||
---
|
||||
|
||||
The remainder of this file is the subagent's reference for executing
|
||||
the workflow.
|
||||
|
||||
## Step 1 — Gather data
|
||||
|
||||
Run these two commands in parallel:
|
||||
|
||||
```bash
|
||||
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.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Sprint Status Output Template
|
||||
|
||||
## Sprint {N}: {Theme} — Status Report
|
||||
|
||||
**Goal:** {goal}
|
||||
**Status:** {status} | {done}/{total} tickets ({pct}%)
|
||||
**Open PRs:** {count} ({branches})
|
||||
|
||||
---
|
||||
|
||||
### Completed ({count})
|
||||
|
||||
| # | Team | Title | Assigned |
|
||||
|---|------|-------|----------|
|
||||
| #{id} | {team} | {title} | {assigned} |
|
||||
|
||||
### In Review ({count})
|
||||
|
||||
| # | Team | Title | PR |
|
||||
|---|------|-------|----|
|
||||
| #{id} | {team} | {title} | #{pr} |
|
||||
|
||||
### In Progress ({count})
|
||||
|
||||
| # | Team | Title | Assigned | Note |
|
||||
|---|------|-------|----------|------|
|
||||
| #{id} | {team} | {title} | {assigned} | |
|
||||
|
||||
### Blocked ({count})
|
||||
|
||||
| # | Team | Title | Blocked by |
|
||||
|---|------|-------|------------|
|
||||
| #{id} | {team} | {title} | #{ids} |
|
||||
|
||||
### Backlog ({count})
|
||||
|
||||
| # | Team | Title | Note |
|
||||
|---|------|-------|----|
|
||||
| #{id} | {team} | {title} | not started |
|
||||
|
||||
---
|
||||
|
||||
### Bookkeeping Issues
|
||||
|
||||
| Issue | Fix |
|
||||
|-------|-----|
|
||||
| {type}: {detail} | `{command}` |
|
||||
|
||||
### Open Work by Team
|
||||
|
||||
| Team | Backlog | In Progress | Review | Blocked | Done |
|
||||
|------|---------|-------------|--------|---------|------|
|
||||
| {team} | {n} | {n} | {n} | {n} | {n} |
|
||||
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
|
||||
@@ -10,68 +10,67 @@ allowed-tools: Bash, Read, Grep, Glob
|
||||
|
||||
# Ticket Skill
|
||||
|
||||
Manage the project ticketing database. Basic usage (`ticket list`, `ticket show`,
|
||||
`ticket sprint --active`) and raw SQL wrappers are documented in CLAUDE.md.
|
||||
This skill covers the full command reference.
|
||||
Manage the project ticketing database. Basic usage is in CLAUDE.md's CLI tools
|
||||
section. This skill covers the full command reference.
|
||||
|
||||
## Commands
|
||||
|
||||
### List tickets (full flags)
|
||||
```bash
|
||||
db/connectors/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
|
||||
tooling/db/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
|
||||
```
|
||||
|
||||
### Create ticket
|
||||
```bash
|
||||
db/connectors/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]
|
||||
```
|
||||
Types: `initiative`, `epic`, `story`, `task`, `bug`
|
||||
Priorities: `critical`, `high`, `medium`, `low`
|
||||
|
||||
### Update status
|
||||
```bash
|
||||
db/connectors/ticket status <id> <new_status>
|
||||
db/connectors/ticket done <id> [<id> ...]
|
||||
tooling/db/ticket status <id> <new_status>
|
||||
tooling/db/ticket done <id> [<id> ...]
|
||||
```
|
||||
Statuses: `backlog`, `ready`, `in_progress`, `review`, `done`, `cancelled`
|
||||
|
||||
### Assignment
|
||||
```bash
|
||||
db/connectors/ticket assign <id> <agent>
|
||||
db/connectors/ticket unassign <id>
|
||||
tooling/db/ticket assign <id> <agent>
|
||||
tooling/db/ticket unassign <id>
|
||||
```
|
||||
|
||||
### Team assignment
|
||||
```bash
|
||||
db/connectors/ticket team <id> <teams>
|
||||
tooling/db/ticket team <id> <teams>
|
||||
```
|
||||
Teams are comma-separated, e.g. `server`, `client`, `server,client`.
|
||||
|
||||
### Sprint management
|
||||
```bash
|
||||
db/connectors/ticket sprint [--active]
|
||||
db/connectors/ticket sprint assign <id> <sprint_id>
|
||||
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: `db/connectors/sprint --help`
|
||||
use the dedicated sprint CLI instead: `tooling/db/sprint --help`
|
||||
|
||||
### Dependencies
|
||||
```bash
|
||||
db/connectors/ticket deps <id>
|
||||
tooling/db/ticket deps <id>
|
||||
```
|
||||
|
||||
### Search and browse
|
||||
```bash
|
||||
db/connectors/ticket search <keyword>
|
||||
db/connectors/ticket epics [--status S]
|
||||
db/connectors/ticket children <id>
|
||||
db/connectors/ticket count [--status S]
|
||||
tooling/db/ticket search <keyword>
|
||||
tooling/db/ticket epics [--status S]
|
||||
tooling/db/ticket children <id>
|
||||
tooling/db/ticket count [--status S]
|
||||
```
|
||||
|
||||
### Batch show
|
||||
```bash
|
||||
db/connectors/ticket show --brief <id> [<id>...]
|
||||
tooling/db/ticket show --brief <id> [<id>...]
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -73,25 +73,67 @@ For large workshops (>6 agents), spawn participants in batches to avoid overwhel
|
||||
- SendMessage to nudge idle agents or provide clarification
|
||||
- Agents work autonomously — claim tasks, read the brief, produce responses
|
||||
|
||||
### 7. Between Rounds
|
||||
### 7. Between Rounds — USER REVIEW CHECKPOINT (MANDATORY)
|
||||
|
||||
When all Round N tasks are complete:
|
||||
1. Verify all agents wrote output files to `docs/workshops/{name}/`. If any are missing, nudge the agent or extract from their message and write the file yourself.
|
||||
2. Qatux reads all `*-round{N}.md` files and produces round summary in `round-{N}-notes.md`
|
||||
3. Create Round N+1 tasks (integration pass, synthesis, etc.) — include the same file output requirement
|
||||
4. Assign to agents with TaskUpdate
|
||||
5. Agents continue working
|
||||
3. **MANDATORY: Present round results to the user via AskUserQuestion before proceeding.**
|
||||
- Summarize the key findings, votes, consensus, and tensions from the round
|
||||
- Present open decisions that need user input (product decisions, scope calls, design direction)
|
||||
- Ask the user whether to proceed to the next round, adjust direction, or add rounds
|
||||
- **Do NOT create next-round tasks or synthesize proposals until the user has reviewed and approved**
|
||||
- The user cannot see agent messages or file contents — present all key information directly
|
||||
4. After user approval, create Round N+1 tasks (integration pass, synthesis, etc.) — include the same file output requirement
|
||||
5. Assign to agents with TaskUpdate
|
||||
6. Agents continue working
|
||||
|
||||
### 8. Wrap Up
|
||||
### 8. Wrap Up — USER CONTROLS SHUTDOWN (MANDATORY)
|
||||
|
||||
**Always ask the user before wrapping up.** There may be more to discuss or additional rounds needed. Only proceed to wrap-up when the user confirms.
|
||||
**The user decides when the workshop ends and when the team is dismissed.** Never initiate shutdown, team cleanup, or wrap-up autonomously. Only proceed when the user explicitly says to wrap up.
|
||||
|
||||
Wrap-up sequence:
|
||||
1. Qatux produces final `workshop-outcomes.md` from accumulated notes
|
||||
2. Qatux creates or updates diagrams (via `/d2-diagram`) for any new D-records produced by the workshop
|
||||
3. If SI is present, SI creates tickets from decided items
|
||||
4. Send shutdown_request to all agents (qatux and si last, after they finish their output tasks)
|
||||
5. TeamDelete to clean up
|
||||
Before the user dismisses the team, the following are **hard requirements**:
|
||||
|
||||
1. **User reviews final outcomes** — Present `workshop-outcomes.md` content to the user via AskUserQuestion. Get explicit approval before proceeding to filing.
|
||||
2. **D-records filed** — All new D-records, amendments, and supersessions are written to `decisions/` domain files. This is non-negotiable — workshops that produce decisions MUST file them before shutdown.
|
||||
3. **Discussion captured** — Qatux produces final `workshop-outcomes.md` from accumulated notes. Qatux creates or updates diagrams (via `/d2-diagram`) for any new D-records produced by the workshop.
|
||||
4. **Tickets created** — If SI is present, SI creates tickets from decided items and the user reviews the ticket list.
|
||||
5. **User gives explicit go-ahead to dismiss** — Only after steps 1-4 are complete AND the user confirms, send shutdown_request to all agents (qatux and si last).
|
||||
6. TeamDelete to clean up.
|
||||
|
||||
**Never shortcut this sequence.** Filing D-records and capturing the discussion are not optional cleanup — they are workshop deliverables.
|
||||
|
||||
## Workshop Format: Interview Mode
|
||||
|
||||
When the workshop brief specifies `**Format:** Interview` (or the user requests "interactive interview mode"), the between-rounds flow changes for the interview round:
|
||||
|
||||
### How Interview Mode Works
|
||||
|
||||
Instead of agents writing responses to each other, the facilitator (team lead) conducts a live interview with the user:
|
||||
|
||||
1. **Collect all agent questions** — Read all Round 1 output files to gather every question.
|
||||
2. **Group thematically** — Organize questions into 5-7 thematic clusters (e.g., "The Vision," "The Confusion Type," "The Emotional Loop"). Questions from different agents that probe similar territory go together.
|
||||
3. **Present via AskUserQuestion** — Present each group using the `AskUserQuestion` tool, one group at a time (1-3 questions per group). For each question:
|
||||
- Include the asking agent's name and domain
|
||||
- Include the full question text with context
|
||||
- Include the agent's reasoning for why the question matters
|
||||
- Provide 2-4 option choices that represent distinct answer categories
|
||||
- Always allow free-text via the "Other" option (automatic)
|
||||
4. **Capture nuance** — The user's free-text notes often contain the most important insights. Capture these verbatim in the transcript.
|
||||
5. **Summarize between groups** — After each group, briefly reflect back the key finding before moving to the next group.
|
||||
6. **Write full transcript** — When all groups are done, write the complete interview to `docs/workshops/{name}/lead-interview.md` with:
|
||||
- Every question and full answer (verbatim where the user provided free text)
|
||||
- Key findings per answer
|
||||
- An interview summary section with the major revelations
|
||||
- "What Survives" and "What Changes" sections
|
||||
|
||||
### Why AskUserQuestion
|
||||
|
||||
The user CANNOT see agent messages, task details, or file contents in the conversation. They only see your text output and AskUserQuestion prompts. Present all question context directly — never assume the user has read agent outputs.
|
||||
|
||||
### Distributing Interview Results
|
||||
|
||||
When creating Round 3 (proposal) tasks after an interview round, include the full transcript path and a summary of the major reframe in every agent's task description. If the user requests it, instruct agents to read the verbatim transcript.
|
||||
|
||||
## Agent Type Reference
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ run_check() {
|
||||
|
||||
# --- Checks ---
|
||||
run_check "tooling/check-fact-ids" "fact_id validation"
|
||||
run_check "tooling/check-decision-ids" "decision ID duplication"
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo ""
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
.cache/
|
||||
.tmp/
|
||||
server/target/
|
||||
server/sr-voice/target/
|
||||
server/models/
|
||||
tooling/content-converter/target/
|
||||
tooling/line-previewer/target/
|
||||
tooling/test-client/target/
|
||||
@@ -43,3 +45,4 @@ Thumbs.db
|
||||
# Note: .claude/agents/, .claude/skills/, and .claude/settings.json ARE tracked
|
||||
.claude/plans/
|
||||
.claude/projects/
|
||||
.claude/agent-memory/
|
||||
|
||||
@@ -6,6 +6,301 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.26] — 2026-03-13
|
||||
|
||||
### Added
|
||||
- ContentType::Factual — lines with numbers, denials, causal chains bypass LLM and serve base text directly (#650, D-138)
|
||||
- Voice pipeline observer integration — enrichment systems rewrite dialogue/conversation text with voiced versions before snapshot assembly (#652)
|
||||
- SQLite settings storage — per-player persistent settings via rusqlite (bundled), IPC protocol v20 with ChangeSettings/RequestAllSettings/DeleteSetting commands (#627)
|
||||
- Composable behavior engine — three-layer action+modifier+context primitives replace flat culture×zone×role behavior strings (#633, D-139, Q-057 resolved)
|
||||
- Stronger few-shot examples for Friendly and RoutineDeviation tells (#651)
|
||||
- AI-Enhanced Dialogue toggle — settings panel toggle with layered hardware detection (RAM/TPT/degradation), battery auto-suspend with player override, warning label (#646, D-138)
|
||||
- PlatformInfo autoload — client-side OS abstraction centralizing all platform queries: power state, memory, CPU, GPU, display, locale, file paths, diagnostics helper (#659, D-141)
|
||||
- Vael and Osse culture profiles with voice personas, behavior modifiers, and explicit NEVER blocks (#653)
|
||||
- Behavior modifiers for all three cultures — 7-category contract: work_pace, physical_manner, social_signal, task_completion, environmental_scan, offduty_posture, authority_response (#634)
|
||||
- Zone-type template architecture — behavior primitives moved from per-location files to reusable zone-type templates (content/global/zone-types/). 31 zone types planned for v1.0 (#661, D-142)
|
||||
- POI three-tier system — large POIs as zone types, abandoned flag for decay variants, poi_overlay for small landmarks (D-142)
|
||||
- Authoring guides for base text elevation, culture creation, and content directory structure
|
||||
- D-140: dialogue re-voicing quality constraints — Paula's six rules
|
||||
- D-142: zone-type template architecture for scalable NPC behavior across 300+ systems
|
||||
|
||||
### Removed
|
||||
- v0.1 content loading system — server/src/content/ module (8200 lines), tooling/content-converter/, tooling/validate-content, content-ron/, content/_meta/ (#655, D-122)
|
||||
- AiDialogueDetector — duplicate of HardwareDetector, replaced by PlatformInfo abstraction (#659)
|
||||
- v0.1 hand-authored Krenn dialogue, monologue, and NPC profiles — 64 files superseded by generated NPCs (#656, D-122)
|
||||
- Detective mission system — investigation knowledge, lattice-commission faction, design docs, workshop archives (#657, D-117)
|
||||
|
||||
### Fixed
|
||||
- Name pool cross-contamination — zero overlaps across Krenn, Vael, and Osse cultures
|
||||
- D-141 → D-142 reference correction in zone-type templates
|
||||
- Modifier coverage expanded to 2+ per category for all cultures; authority_response differentiated Krenn/Osse
|
||||
- Gendered pronouns removed from culture-neutral zone-type templates
|
||||
- Ungrounded lore terms (Syndic, Meridian registration) replaced with generic descriptors in Osse culture
|
||||
- Stale notes in content-structure-canonical.md and base-text-authoring-guide.md corrected
|
||||
- Legacy annotation added to overheard.yaml (#664 tracks replacement)
|
||||
- Dead dual_lens properties stripped from environmental YAML
|
||||
|
||||
### Changed
|
||||
- Protocol version bumped to 20 — ObserverSnapshot includes settings_response field (#627)
|
||||
- All 18 agent briefings updated for v0.2 pivot — removed detective/smuggler/hand-authored references, aligned with generator-first approach (#658)
|
||||
- 5 agent profiles (miri, ozzie, paula, inigo, hoshe) updated to remove stale v0.1 framing (#658)
|
||||
- Q-015 closed as obsolete (D-122 eliminates hand-authored FRIEND content)
|
||||
- D-018 perception model: franchise-specific example replaced with generic framing
|
||||
|
||||
## [v0.1.25] — 2026-03-07
|
||||
|
||||
### Fixed
|
||||
- Name pool first-pick bias — generator spike produced "Dav" as NPC 1 across all seeds; now uses derived RNG per zone+culture (#628)
|
||||
- Behavior dedup — same behavior string no longer assigned to multiple NPCs in one zone run (#629)
|
||||
|
||||
### Added
|
||||
- Zone identity specs renamed to location-specific: krenn-rural-zone.ron and krenn-industrial-zone.ron — acknowledges these are culture×zone content, not reusable templates (#630, Q-057)
|
||||
- ~108 new NPC behavior pool entries across all roles in both zone files — trader stage directions, foreman humanity behaviors, dock_worker/technician off-shift/break room behaviors (#630)
|
||||
- Q-057 open question: composable behavior generation — decompose hand-authored pools into role actions + culture modifiers + context tags (#633, #634)
|
||||
- Relationship-to-behavior pipeline — NPC behavior lines now reflect social connections (rivals ignore each other, friends gravitate, subordinates defer) (#631)
|
||||
- Want/State layer — NPCs have internal motives (Bored, Alert, Suspicious, AvoidingSomeone, LookingForInfo) that leak through observable micro-tells (#632)
|
||||
- LLM voice pipeline — Spike 1 (sr-voice CLI) and Spike 2 (full pipeline integration) complete. Gemma 2B Q4_K_M via stdin/stdout JSONL pipes, composition engine with double-prompt technique, 39 quality test cases (#638-644, D-138)
|
||||
|
||||
## [v0.1.24] — 2026-03-06
|
||||
|
||||
### Changed
|
||||
- Replaced all in-universe "Commonwealth" references with "the Settled Reach" across 44 files (agents, decisions, docs, tooling, server). Historical discussion transcripts and Hamilton book references kept as-is.
|
||||
|
||||
### Added
|
||||
- Character archetype select screen — two-card UI (Smuggler/Detective) between New Game and session start, keyboard+mouse selection, ESC cancels (#588, D-027)
|
||||
- Triangle activation consumer — urgent monologue chime fires once per triangle per session when triangle_crisis_events received (#590, D-039)
|
||||
- News ticker HUD — scrolling marquee visible in The Last Shift zone, hidden elsewhere, reads current_ticker from snapshot (#592, D-039)
|
||||
- Triangle activation proximity monologue lines — 5 smuggler lines (Kael Davan) and 5 detective lines (Sera Venn/Torek Lintar) that fire when observing triangle anchor NPCs post-activation (#597, D-035, D-039)
|
||||
|
||||
### Changed
|
||||
- Protocol version bumped to 19 — StartupMessage includes character_archetype, snapshot includes triangle_crisis_events and current_ticker (#588, #590, #592)
|
||||
|
||||
## [v0.1.23] — 2026-03-04
|
||||
|
||||
### Added
|
||||
- TileKind enum (Floor/Wall/Void/Restricted) on WalkabilityMap with per-tile type data alongside walkability (#576, D-094)
|
||||
- Location YAML tile format — hand-author tiles as string arrays (F/W/V/R characters), loaded into WalkabilityMap on production startup (#577)
|
||||
- Chunk streaming system — ChunkLoadRadius and cadence-gated load/unload around player position, v0.1 covers full district (#578, D-012)
|
||||
- EngagementRecord component — per-NPC observation time, conversation count, and monologue trigger count tracked by perception/dialogue/monologue systems (#570)
|
||||
- MovementHistoryBuffer resource — 3000-tick ring buffer of player positions with co-presence proximity query (#571)
|
||||
- Storyteller lifecycle rules — single activation per session, no concurrency, terminal resolution constants (#572)
|
||||
- Storyteller activation_pass() — gate/proximity/engagement scoring/routing/module selection/TriangleActivatedEvent on 10-tick cadence (#579)
|
||||
- Debug console server — 10 DebugCommandKind variants (AdvanceTicks, SkipToContamination, TeleportToPosition, InspectNpc, ListTriangles, etc.) with DebugResponsePayload on ObserverSnapshot (#580)
|
||||
- Debug console client — tilde-toggle UI panel with command input, output log, settings toggle, and full DebugCommandKind dispatch via protocol v18 (#581)
|
||||
- Entity-bound dialogue speaker colors — NPC colors assigned by entity ID (not screen position) with per-conversation lifecycle and round-robin palette (#573)
|
||||
- Sova Transit District tile maps — 5 locations authored: The Terminal (44×28), The Last Shift (34×22), Maintenance Corridors (58×6), Gate Ground (40×34), Gate Gallery (32×10) (#582, #583)
|
||||
|
||||
### Fixed
|
||||
- LOS boundary walls — 1-tile wall margin beyond vision cone included in visible_tiles as BoundaryWall sector, walls at fog edge now render instead of bleeding into fog (#584)
|
||||
- LOS boundary walls client — BoundaryWall tiles render through fog without marking explored, 4 new fog tests verify lifecycle (#585)
|
||||
- Entity renderer test failures — updated 7 stale ColorRect/position assertions for Sprite2D migration, fixed SoundIndicatorRenderer class cache (#574)
|
||||
- Dialogue speaker color contrast — re-enforce contrast floor after desaturation for passive (overheard) lines
|
||||
- PROTOCOL_VERSION 17 → 18 mismatch — client rejected every server snapshot
|
||||
- Debug console D-088 pause — sim now pauses while console is open, matching dialogue/settings overlay behavior
|
||||
- Debug console settings toggle reads live state instead of ConfigFile, preventing checkbox divergence
|
||||
|
||||
### Changed
|
||||
- PROTOCOL_VERSION bumped 17 → 18 (debug_response field on ObserverSnapshot, DebugCommand PlayerAction variant)
|
||||
|
||||
## [v0.1.22] — 2026-03-03
|
||||
|
||||
### Added
|
||||
- Visual test harness — `make screenshot`, `make test-visual`, `make visual-update` for automated visual regression testing with golden PNGs across 11 scenarios (fog, HUD, dialogue, minimap)
|
||||
- Visual movie mode — `make visual-movie` captures interaction flows as frame sequences with contact sheet generation
|
||||
- World seed protocol — StartupMessage carries world_seed from client to server after handshake, enabling deterministic NPC population seeding (D-010, D-029)
|
||||
- EntanglementConfig — per-seed NPC population ratios (flat/mundane/intrigue) sampled from seeded RNG with D-029 bounds, ensuring same seed = same world (#175, #178)
|
||||
- Fog debug mode — toggle FogState.debug_exploration to render raw exploration texture as colored overlay for diagnostic use
|
||||
- D-110 through D-112: z-level addressing, subterranean architecture, no instancing decisions
|
||||
- Q-051: speech bubble indicator over speaking NPCs
|
||||
- Sprint 22 "Wire" briefings (server, client, visual, CI, planning, joint)
|
||||
|
||||
### Added (server)
|
||||
- Production NPC pool generation — 23 authored Sova NPCs spawn with EntanglementTag (Flat/Intrigue) based on triangle membership (#176, D-029)
|
||||
- Authored triangle instantiation — 5 Sova triangles (3 active forks, 2 passive tensions) loaded from content YAML with deterministic IDs (#188, D-087)
|
||||
- Contamination activation mechanic — timer-based storyteller fires after 30 game-minutes, pressures active triangles, emits ContaminationEvent (#254)
|
||||
- Modifications data model stub — Vec<Modification> on chunk entities, round-trips through save/load for future construction DLC (#567, D-112)
|
||||
- Zone Gate gauntlet room — two-zone test room with door boundary, zone crossing detection system (#512)
|
||||
- Fuzzy map tests — 50-seed randomized testing of procedural maps against 4 structural invariants (#509)
|
||||
|
||||
### Fixed
|
||||
- Fog shader: silent compilation failure in OpenGL3 compat mode — removed `return` statements from fragment() which are not supported, causing fog overlay to render as no-op (root cause of Sprint 22 fog regression)
|
||||
- Fog system: blocky stair-stepped edges at vision cone boundary — doubled Gaussian blur step size for D-066 compliant 6-8 tile smooth gradient (#569)
|
||||
- Fog system: zero visibility in explored areas — switched bounds calculation from visible_tiles (empty in live server mode) to visible_positions, and removed shader guard that cut off gradient bleed into unexplored tiles (#569)
|
||||
- Fog shader alpha tuned to D-059 spec: light fog 0.25-0.35 (was 0.25-0.55), deep fog 0.55-0.70 (was 0.78-0.90) — world content now visible through fog instead of hidden behind it (#563)
|
||||
|
||||
### Changed
|
||||
- Fog shader now distinguishes light fog (near cone, neutral dark) from deep fog (far from cone, zone temperature tint) with separate Perlin noise breathing cycles (8-10s / 15-20s)
|
||||
- Zone temperature tint populated per-tile from server zone_id: bar=warm amber-dark, hub=cool blue-dark, corridor=neutral dark (D-059/D-046/D-077)
|
||||
- Simplified vision cone from 3-sector (forward/peripheral/blind) to forward-only 120° arc — server sends only forward-cone tiles, client renders explored tiles behind the player with light fog overlay
|
||||
- Simplified fog shader from 5-layer to 3-layer model (clear, explored, unexplored)
|
||||
- Fog texture resize now preserves exploration data — tiles behind the player stay as light fog instead of reverting to unexplored black
|
||||
- Updated D-015/D-017 perception decisions to reflect simplified cone model
|
||||
- Moved connector scripts from db/connectors/ to tooling/db/ (#274) — backwards-compat symlink removed in #568
|
||||
|
||||
### Removed
|
||||
- db/connectors symlink — all references now use tooling/db/ directly (#568)
|
||||
|
||||
## [v0.1.20] — 2026-02-25
|
||||
|
||||
### Added
|
||||
- Social site template schema — RoleSchema (#163), SpaceSpec (#164), TriangleDef (#106) with YAML deserialization, sample templates at server/data/templates/
|
||||
- Single-ownership model — TemplateOwnership component, TemplateReferenceMap resource, cross-template reference links preserved across save/load and tier eviction (#165, D-025)
|
||||
- Triangle generation — intra-template constraint satisfaction assigns NPCs to triangle roles, minimum 2 triangles per template with fallback on imperfect seeds (#107)
|
||||
- Triangle escalation system — tick_triangle_escalation runs per game-minute, tension increments toward ToleranceThreshold, TriangleCrisisEvent emitted on Active phase entry, ResolveTriangle stub command (#250, D-087)
|
||||
- Protocol v16 — TriangleCrisisEventWire on ObserverSnapshot for future client rendering of triangle crises
|
||||
- D-093: Sova Transit District spatial layout — 4 social sites (Terminal, Bar, Gate Cluster, Sector 3), 2 encounter nodes, zone palette, gate cluster 7-zone spec, z-level scheme (z=0 maintenance, z=1 main, z=2 observation gallery), 3 investigation paths, corridor widths
|
||||
- D-094: Spatial hierarchy — chunk (64×64 sim) → block (128×128 sim) → district (4×4 blocks, 256×256 visual), supersedes D-014 estimate
|
||||
- D-095: Horizon stations and transport lore — span gates (human-built, dual-use), horizon stations (alien-built, 4-8 apertures), "The Ring" per-system naming, sequential hop travel, The Loop internal tram
|
||||
- Generator architecture workshop brief (ticket #562) — top-down pipeline for district generation, targeting Q-036 resolution
|
||||
- SnapshotEventRouter — callable-based snapshot dispatch replaces inline if-has blocks in main.gd (#559)
|
||||
- YamlParser shared utility — unified YAML parsing for UI strings and checklist conditions (#560)
|
||||
|
||||
### Fixed
|
||||
- Wire triangle crisis event queue into observer snapshot — clients now receive TriangleCrisisEventWire via protocol v16 (was always empty)
|
||||
- Persist TriangleState in SaveStateV1 — triangle phase and tension survive save/load cycles
|
||||
- Validate dangling with_role references in TriangleDef constraint validation
|
||||
- Replace O(n²) fallback NPC assignment with BTreeSet; prevent same NPC assigned to two roles in one triangle
|
||||
- Replace O(N*M) scan in apply_resolve_triangle with BTreeMap index for O(1) per-command lookup
|
||||
- Add From impls for RoleId, TriangleId, StableId, TriangleCrisisEventWire — eliminate fragile .0 newtype access
|
||||
- Consolidate near-identical unit tests with integration counterparts
|
||||
|
||||
### Changed
|
||||
- Sova station profile updated — horizon gates located at The Krenn Ring (800 AU), not on Station Sova; Admin Hub houses transit processing facility only
|
||||
- game_state.gd: stationary_ticks and zone_id now read from server snapshot with deprecated client-side fallbacks (#557, D-020)
|
||||
- dialogue_box.gd: decoupled from GameState and AudioManager via signals — zero direct autoload references (#558, D-020)
|
||||
- main.gd: snapshot dispatch via SnapshotEventRouter, dialogue signal coordinator handlers (#559, #558)
|
||||
- ui_strings.gd and checklist_evaluator.gd: delegate to YamlParser, ~140 lines of duplication removed (#560)
|
||||
|
||||
## [v0.1.19] — 2026-02-25
|
||||
|
||||
### Added
|
||||
- Sprint 20: Shape planned — 11 tickets (server 6, client 4, planning 1) covering template/triangle schemas, client refactors, and district layout design discussion
|
||||
- Planning team ticket type in sprint-plan skill — supports design discussions with purpose-assembled agent panels, Qatux and SI for bookkeeping
|
||||
- Client PR #70 merged — save/load client UI, F5/F6 quicksave/quickload (#554)
|
||||
- Server PR #68 merged — Sprint 19 save/load, tier eviction, test infra (7 tickets, 2714 lines)
|
||||
- Client PR #67 merged — Sprint 19 test infra, session management, debug overlay (5 tickets, 2547 lines)
|
||||
- CI PR #69 merged — Sprint 19 test runners, IPC fixtures, protocol handshake, benchmark (4 tickets, 1297 lines)
|
||||
- Test runner scripts — 7 bash scripts (run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration, run-ipc-benchmark, run-all) with structured JSON output (#270, D-030)
|
||||
- IPC serialization fixtures — 5 msgpack fixtures with Rust generator, cross-language GDScript validation (22 assertions) (#271, D-030)
|
||||
- Protocol handshake client — HANDSHAKING state in SimBridge, HandshakeMessage decode with 5s timeout (#556, D-020)
|
||||
- IPC round-trip benchmark — p50/p95/p99 latency reporting, 5ms threshold (#342, D-020)
|
||||
- Protocol version handshake — `HandshakeMessage` as first IPC frame before tick loop, forward-compatible input handling (#555, D-020)
|
||||
- Protocol v15 — `save_result` field on ObserverSnapshot for client save/load confirmation
|
||||
- State serialization primitives — `serialize_npc_to_frozen`/`deserialize_npc_from_frozen` with full D-024 10-axis coverage for tier eviction freeze/thaw (#96, D-026)
|
||||
- Scope tag system — `ScopeTagKind` (Neighborhood, ActiveQuest, Colleague, KnownContact), `ScopePinned` marker, automatic assignment from KnowledgeGraph and RelationshipGraph (#98, D-026)
|
||||
- Timestamp-based eviction — `LastInteractionTick` LRU tracking, `SimSpacePressure` resource, BinaryHeap eviction respecting scope-pinned entities, Active cap 80 (#97, D-026)
|
||||
- Save/load ECS extraction — `save_to_file`/`load_from_file` via MessagePack, `SaveGame`/`LoadGame` IPC commands, `SaveLoadResultWire` on ObserverSnapshot (#553, D-085)
|
||||
- ScopePinned eviction regression test — adversarial at-scale test proving pinned NPCs survive eviction even with oldest ticks
|
||||
- Test infrastructure — Layer 3 integration test entry point, three-layer architecture per D-030 (#200)
|
||||
- Information boundary negative tests — 4 tests proving no passive KG leakage, LOS fog holds, tier boundary holds, per-NPC save isolation (#272, D-010)
|
||||
- gdUnit4 CI runner script — headless test execution via `run_gdunit4.gd` with exit code for CI (#205)
|
||||
- Scene testing utilities — SceneHelper class with node existence, signal, and path helpers for gdUnit4 (#206)
|
||||
- GameState apply_snapshot tests — 14 tests covering v2+ fields: game_time, facing, interactions, monologue, stance, inventory (#206)
|
||||
- Game session management — per-game save directories under `user://saves/<timestamp>-<seed>/` per D-085, SessionManager autoload, main menu scene (#258)
|
||||
- Debug visualization overlay — F3-toggled dev overlay with LOS rays, vision cone arcs, NPC path trails, knowledge confidence tags, tick timing sparkline (#348)
|
||||
- SimBridge→TestHarness extraction — test simulation logic separated into dedicated RefCounted class with backward-compat proxy API
|
||||
- Workshop outcomes files — formal closure for content-gap-analysis, KG-information-boundaries, v01-content-scoping, v01-gap-analysis, wiki-review
|
||||
- D-087 through D-092 — recovered decisions from v01-content-scoping and wiki-review workshops (triangle config, pause system, content scope, voice registers, anchor lines, complicity theme)
|
||||
- Q-030 through Q-039 — open questions from workshop backlog (seed schema, style guide, cultural ingredients, NPC architecture, PC archetypes, sacred/profane framework, district skeleton, generator pipeline, authored content estimate, gate topology)
|
||||
- Decision ID claim system — `db/connectors/decision` CLI with `next`, `claim`, `check-dupes` commands to prevent cross-worktree D/Q/R ID collisions, pre-commit duplicate check
|
||||
- D-085: Per-game save directory structure — every new game creates `user://saves/<game-id>/`, F5 quicksave, F6 quickload
|
||||
- Q-029: Save file format design — long-term considerations for versioning, compression, integrity, metadata headers
|
||||
- D-086: Renumbered insert icon system (was D-084 on visual branch) to resolve cross-worktree ID collision
|
||||
- Save/load wireframe updated for D-085 — LOAD tab shows games grouped by directory with expand/collapse, QUICKSAVE slot, F5/F6 hints
|
||||
- Sprint 19: Persist planned — 16 tickets (server 7, client 5, CI 4) covering save/load, tier eviction/scope, test infrastructure
|
||||
- Character creation & game setup workshop brief — covers creation model, seed boundary, gate activation, quest seeding, game toggles (resolves Q-011)
|
||||
- Protocol v14 — `poi_list`, `examine_result`, `player_knowledge` ObserverSnapshot wire types with live KG serialization (#151, #174, #264)
|
||||
- Minimap rendering — circular 160px diegetic insert overlay with POI dots (colored by category), border arrows for distant POIs, player-centered fixed-north (#151)
|
||||
- Dialogue UI hardening — confrontation italic voice (D-063), examine result overlay with 5s auto-dismiss and confidence coloring (#174)
|
||||
- Knowledge/journal panel — right-side insert panel (J key), facts grouped by entity, contradicted entries in amber with strikethrough, stale entries dimmed, mutual exclusion with dialogue (#264)
|
||||
- Sprint 18 client test suite — 50 gdUnit4 tests for dialogue (D-062, D-063, D-064) and journal (KG parsing, scene structure, UIStrings), plus test plan document
|
||||
- D-084: dual-namespace line ID scheme for auto-generated NPCs — role pool (shared, unchanged) + instance override (opt-in, seeded counter). Resolves Q-028 (#544)
|
||||
- Tier 1 drama module schema (`content/schemas/drama_module.schema.yaml`) — entry conditions, NPC requirements, event sequences, outcomes, pool format (#158)
|
||||
- Smuggling ring v0.1 stub module (`content/modules/tier1/smuggling_ring_v0_1.yaml`) — vertical slice Tier 1 module with 6 NPC roles, dual event sequences, 5 outcomes (#158)
|
||||
- Line ID authoring guide (`docs/design/line-id-authoring-guide.md`) — dual-namespace conventions for hand-authored and auto-generated NPC content
|
||||
- Tier 1 module authoring guide (`docs/design/tier1-module-authoring.md`) — field reference, NPC pattern/motivation tables, design principles, pre-submission checklist
|
||||
- Background tier state machines — schedule, mood, relationships, job tick once per game-minute for Background NPCs (#95, D-026)
|
||||
- NPC vision system — symmetric shadowcasting for Active-tier NPCs, NpcMemory with last-known-position and zone inference (#115, D-011)
|
||||
- NPC player-awareness behavior — PlayerAwareness component tracks LOS duration, suspicion accumulation, routine deviation triggers (#244)
|
||||
- Skill system & combat flag — SkillSet component (BTreeMap<String, u8>), CombatCapability marker from combat_trained skill (#91, D-024)
|
||||
- Player-action social propagation — three-order trust ripple (100%/40%/20%) through RelationshipGraph with cycle prevention (#249, D-029)
|
||||
- Examine mechanic — process_examine_interaction with character-filtered observation text, KG DirectObservation write, examine_result in ObserverSnapshot (#242)
|
||||
- Character goal/pressure framework — CharacterPressure component (exposure/institutional/relationship), wired to snapshot HUD data (#248)
|
||||
- Save state data model — SaveStateV1 struct with MessagePack serialization, roundtrip tests for entity/KG/relationship/clock state (#256)
|
||||
- Tell state derivation wired into ObserverSnapshot — integration tests for Nervous tell on Major secret + high stress (#337)
|
||||
- Sprint 18: Touch planned — 14 tickets (server 9, client 3, copy 2) covering examine mechanic, NPC awareness, social propagation, minimap, dialogue UI, save state model
|
||||
- `.claude/rules/` directory — modular auto-loaded instructions (tea-cli, git-safety, project-structure, team-patterns, local-services)
|
||||
- KnowledgeGrant untagged enum with Fact and Entity variants, ContentEntityRegistry for NPC spawn-time entity resolution (D-079, #545)
|
||||
- KnowledgeGranted event processing — grants fire at dialogue line selection, runtime NPC KG guardrail (D-079, #546)
|
||||
- ContradictionClaim struct with 600-tick window detection in observe_entity, epistemic neutrality for both sources (D-083, #547)
|
||||
- NPC-to-NPC knowledge transfer system — trust-gated fact exchange, confidence capping at KnowsOf, ToldBy source construction (D-080, #548)
|
||||
- tell_state KG awareness — NPC relationship reads from KG for other-entity state, MVP information boundary (D-082, #549)
|
||||
- Contradiction monologue with pre-resolved entity names, PersonOfInterest relationship shift, THE FRIEND arc event chain (D-083, #550)
|
||||
- Unprompted disclosure system — DisclosureCandidates component, 7 trigger gates, three-layer rate limiting, two-stage trait filter (D-081, #551)
|
||||
- Trait modifier system — Cautious/Gossipy/Loyal/Talkative filter predicates via content-authorable config (D-081, #173)
|
||||
- POI data model and proximity-based discovery system via KnowledgeGranted events (#148, #149)
|
||||
- Protocol versioning tests — version round-trip, mismatch detection, serde_default migration pattern, full variant coverage (#232)
|
||||
- Team monitoring rules — heartbeat rule for stuck agent detection, bottleneck detection pattern
|
||||
- `tooling/tea-comment` — single-command wrapper for posting Gitea PR/issue comments with multi-line bodies
|
||||
- D-086: Insert icon system — custom SVG icons over icon fonts, authored to insert geometric constraints with lattice_profile weight scaling
|
||||
- Insert/HUD wireframe and visual spec (#314) — dual character variants (smuggler social network view, detective investigation overlay) with pixel-precise layout, entity markers, time display, border arrows, commission grid, and all interaction states
|
||||
- Contradiction monologue lines — 16 hand-authored lines (8 detective, 8 smuggler) for Sera/Kael FRIEND arc, Phase 2 blindsiding + Phase 3 pattern recognition, cognitive-dissonance-not-accusation tone per D-083 (#552)
|
||||
- Diegetic tutorial monologue — 20 lines (10 per character) teaching movement, fog, sound, NPC interaction, and insert/HUD through character voice, fire-once on first-time events (#330)
|
||||
- Diegetic time display on insert HUD — station local time (HH:MM), day phase with cycle-tinted color, day number on InsertOverlay (#263)
|
||||
- Relationship color accent on E-Talk overlay — 3px left-edge bar using D-033 palette signals NPC relationship at a glance (#537)
|
||||
- `Constants.format_game_time()` helper for converting game-minutes to HH:MM station time
|
||||
- `/sprint-status` cleanup sweep skill — consistent health report with tickets by status, PR cross-reference, bookkeeping issue detection, and open work by team
|
||||
- `sprint sweep` CLI subcommand — structured JSON output for sprint health checks (grouped tickets, per-team summary, issue detection)
|
||||
- Knowledge Flow & NPC Boundaries workshop — 5 D-records (D-079–D-083) covering grant architecture, NPC-to-NPC propagation, unprompted disclosure, NPC information boundaries MVP, contradiction detection pipeline
|
||||
- 7 knowledge graph implementation tickets (#545–#551) with full dependency chain and line estimates
|
||||
- Contradiction monologue content ticket (#552) for Sera/Kael FRIEND arc
|
||||
- Sprint 17 completion proofs: contradiction detection fires, NPC-to-NPC knowledge transfers
|
||||
- Entity renderer migrated from ColorRect placeholders to Sprite2D with D-019 angle sprites — self_modulate for D-033 tinting, 8→4 octant direction mapping, feet-anchored y-sort (#540)
|
||||
|
||||
### Fixed
|
||||
- Client protocol version bumped to 15 to match server (was still at 14 after server PR #68 added save_result field)
|
||||
- gen_fixtures.rs version comments changed from hardcoded 14 to PROTOCOL_VERSION constant
|
||||
- run-ipc-benchmark dead --iterations flag removed (Rust compile-time constant governs rounds)
|
||||
|
||||
### Changed
|
||||
- Team boundary framing — replaced worktree-centric language with `$WORKTREE_TEAM` env var identity across CLAUDE.md and skills (sprint-start, sprint-plan, pr-review) to prevent agents from following `.git` pointers across boundaries
|
||||
- CLAUDE.md compacted from 188 to 67 lines — CLI references, endpoints, and patterns moved to `.claude/rules/`
|
||||
- `/sprint-status` delegates to haiku subagent — keeps sweep JSON, template read, and PR list out of main context window
|
||||
- `sprint sweep` JSON trimmed — removed unused fields (`ok`, `sprint.status`, `priority`, `ticket_id`), shortened issue detail strings
|
||||
- Sprint status output template condensed — rendering rules moved to skill definition, bookkeeping table simplified to 2 columns
|
||||
- Model selection documented in CLAUDE.md — `/model sonnet[1m]` and `/model opus[1m]` for 1M context sessions
|
||||
- Sprint 17 briefings updated with workshop results — server (14 tickets), copy (2 tickets), client (2), visual (1)
|
||||
- Q-024 (gossip timing), Q-025 (KG memory), Q-026 (contradiction detection) closed
|
||||
- Sprint 16 closed (8/8 done)
|
||||
- 3D sprite render pipeline — Camera3D at D-019 angle (-72.5° from horizontal), three-point studio lighting rig, orthographic projection, resolution chain 1024→256→64
|
||||
- Generic NPC capsule model (24×32px footprint per D-044) and structural wall model for pipeline validation
|
||||
- Test sprites: 8 runtime 64px sprites (NPC + wall × 4 directions) deployed to client/assets/sprites/
|
||||
- Pipeline documentation (renderer/README.md) — camera spec, lighting rig, resolution chain, model authoring guide
|
||||
- DialogueResponse verb handler — players pick dialogue options and receive follow-up lines via full D-028 four-layer pipeline (#539)
|
||||
- Trust-gated gossip verification — integration tests confirm Secret/Real/Surface tier gating per D-075 (#171)
|
||||
- Line variety tracker wiring — DialogueCooldownTracker prevents repeat lines within 600-tick window (#338)
|
||||
- DialogueResponse cross-language fixture for GDScript testing
|
||||
- Sprint team lifecycle through PR review — teams stay alive for commit → push → review → fix loop → approve → shutdown
|
||||
- Zone_id extraction in game_state.gd optimized from O(N) tile scan to O(1) dictionary lookup — builds _tile_by_coord from member visible_tiles covering both test and live paths (#543)
|
||||
- Shared run_dialogue_pipeline() helper eliminates ~60 lines of duplication between Talk and DialogueResponse systems
|
||||
- Dialogue and monologue line IDs migrated from location-scoped (the-terminal_d_039) to NPC-scoped (kael-davan_d_001) namespace — each NPC has an independent sequence per D-035 (#542)
|
||||
- DialogueCooldownTracker documented as per-player-global by design (NPC-scoped line IDs per D-035 prevent collision)
|
||||
- CONFRONTATION_LINES marked TODO for migration to D-028/D-035 content pipeline
|
||||
- pr-push and pr-review skills updated with team lifecycle awareness
|
||||
|
||||
### Fixed
|
||||
- PR #59 review: stale mood vocabulary updated in line-pool-format.md, style-guide, and content-directory-structure.md to post-Sprint 14 values
|
||||
- PR #59 review: orphaned location-scoped IDs in maintenance-tech.yaml comments and smuggler-inventory.yaml cross-references updated to NPC-scoped
|
||||
- PR #59 review: Lera Sessik tenure corrected from "twelve years" to "eighteen years", NPC header fixed
|
||||
- PR #59 review: ring-operative.yaml fact_id corrected from `location.surveillance_gaps` to `investigation.surveillance_gaps`
|
||||
- Dialogue systems moved from BridgePlugin to NpcPlugin — game logic registers where it belongs (#538)
|
||||
- Schedule ambiguity: emit_observation_events now has explicit .before(advance_tick) constraint
|
||||
- process_dialogue_response updates ActiveDialogue tick and InteractionMemory on follow-up
|
||||
- DialogueResponse range check added (CLOSE_RANGE, matching Talk/Confront pattern)
|
||||
- Weighted selection fallback replaced with unreachable!() — dead code removed
|
||||
- assert!(false) → panic!() in serialization tests (clippy)
|
||||
- SetFacing and TeleportToHub added to roundtrip test coverage
|
||||
|
||||
## [v0.1.15] — 2026-02-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,68 +1,42 @@
|
||||
# The Settled Reach
|
||||
|
||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
|
||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC.
|
||||
|
||||
**Official Title:** The Settled Reach (D-021)
|
||||
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
|
||||
**Official Title:** The Settled Reach
|
||||
**Repository name:** settled-reach
|
||||
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
client/ # Godot 4 client (D-020)
|
||||
server/ # Rust/bevy_ecs simulation server (D-020)
|
||||
client/ # Godot 4 client
|
||||
server/ # Rust/bevy_ecs simulation server
|
||||
tooling/ # Build tools, scripts, asset pipelines
|
||||
tests/ # Integration and end-to-end tests
|
||||
.config/ # Configuration files (linters, formatters, CI)
|
||||
.cache/ # Local caches for testing/linting (gitignored)
|
||||
docs/
|
||||
discussions/ # Discussion rounds (all rounds archived here per D-022)
|
||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||
architecture/ # Technical architecture documents
|
||||
design/ # Game design documents
|
||||
diagrams/ # d2 source + PNG renders (architecture, data-flow, entity, state, ui)
|
||||
sprints/ # Sprint briefings per team (server.md, client.md, copy.md, joint.md, etc.)
|
||||
workshops/ # Workshop briefs and outputs (per-workshop subdirectories)
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
connectors/ # Connector scripts for SQLite and Qdrant
|
||||
config.json # Endpoint configuration
|
||||
ticket # Ticket CLI (list, show, create, assign, sprint, etc.)
|
||||
sqlite_connector.py # SQLite mini MCP
|
||||
qdrant_connector.py # Qdrant + ollama mini MCP
|
||||
.claude/
|
||||
agents/ # Agent personality files
|
||||
skills/ # Skill definitions
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index and query examples
|
||||
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
|
||||
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
|
||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
|
||||
process.md # D-004, D-021, D-022
|
||||
questions.md # Q-001 through Q-011
|
||||
rejected.md # R-001 through R-010
|
||||
DECISIONS.md # Redirect to decisions/ directory
|
||||
TEAM.md # Team roster and roles
|
||||
docs/ # Architecture, design, briefings, sprints, workshops
|
||||
db/ # Schema + seed data (connectors moved to tooling/db/)
|
||||
.claude/ # Agents, skills, rules
|
||||
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
|
||||
```
|
||||
|
||||
Full annotated tree: `.claude/rules/project-structure.md`
|
||||
|
||||
## DevOps
|
||||
|
||||
See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. All development operations go through the top-level `Makefile` — run `make` for a summary of targets.
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
### Worktree boundaries
|
||||
### Team boundaries
|
||||
|
||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) is checked out in its own worktree under that parent. The parent directory also contains shared resources like the ticketing database.
|
||||
**Your team identity is `$WORKTREE_TEAM`.** All work must stay within the current working directory.
|
||||
|
||||
Each worktree contains the full repository: `server/` (Rust backend), `client/` (Godot client), `docs/`, `decisions/`, etc. The worktree root IS the git root — use `git rev-parse --show-toplevel` if in doubt.
|
||||
|
||||
Unless there is a direct instruction or a functional need (e.g. accessing the shared database in the parent directory), **all work must remain within the scope of the git root Claude is running in.**
|
||||
|
||||
- All file paths are relative to the worktree/git root (e.g. `server/src/bridge/types.rs`, `client/scripts/rendering/fog.gd`).
|
||||
- Do not navigate to or access sibling worktrees in the parent directory (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||
- Do not navigate above the git root unless explicitly instructed.
|
||||
- All file paths are relative to the current working directory (e.g. `server/src/bridge/types.rs`).
|
||||
- **Do NOT navigate to parent or sibling directories** (`../`, `../client/`, etc.) unless explicitly instructed. Do NOT use absolute paths to reach other team directories.
|
||||
- **Do NOT write auto-memory files for other teams.** If `$WORKTREE_TEAM` is `server`, do not write to memory paths containing `client`, `main`, etc.
|
||||
- For context: each team has its own directory via git worktrees, sharing a parent directory (`settled-reach/`). The `.git` file points to a shared git directory — do not follow it to determine your working root.
|
||||
- **Exception — stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file for **your own team only** (e.g. `main/.git/worktrees/$WORKTREE_TEAM/index.lock`). Never touch lock files belonging to other teams.
|
||||
- **Never chain git commands** in a single Bash call (e.g. `git add ... && git commit ...`). The shared `.git` directory means concurrent index access from the same terminal creates `index.lock` collisions. Always run `git add` and `git commit` as **separate sequential Bash calls**.
|
||||
|
||||
### Database
|
||||
|
||||
@@ -70,114 +44,36 @@ The ticketing database (`settledreach.db`) lives in the **parent directory** sha
|
||||
|
||||
### Before starting work
|
||||
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
|
||||
2. Use `db/connectors/ticket show <id>` for full ticket details
|
||||
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/`
|
||||
|
||||
### Ticket and database access
|
||||
**Prefer the ticket CLI over raw SQL.** The CLI handles column names, joins, and output formatting correctly:
|
||||
```bash
|
||||
db/connectors/ticket list --sprint 2 --team server
|
||||
db/connectors/ticket show 78
|
||||
db/connectors/ticket sprint --active
|
||||
```
|
||||
### CLI tools
|
||||
|
||||
### Sprint CLI
|
||||
**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption:
|
||||
```bash
|
||||
db/connectors/sprint status # Current sprint progress
|
||||
db/connectors/sprint status --team server # Team-scoped view
|
||||
db/connectors/sprint start-work --team client # Full context dump for starting work
|
||||
db/connectors/sprint prepare # Prepare next sprint (candidates + gaps)
|
||||
db/connectors/sprint start # Activate a planned sprint
|
||||
db/connectors/sprint stop # Complete an active sprint
|
||||
```
|
||||
Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state.
|
||||
**Prefer CLI wrappers over raw SQL.** Never use the `sqlite3` CLI — it crashes in Claude Code (std::bad_alloc). Use the wrapper scripts instead.
|
||||
|
||||
Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead:
|
||||
```bash
|
||||
db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
|
||||
db/connectors/sqlite-exec "UPDATE tickets SET status='done' WHERE id=1"
|
||||
```
|
||||
| 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 |
|
||||
| SQL queries | `tooling/db/sqlite-query "SELECT ..."` | — |
|
||||
| SQL writes | `tooling/db/sqlite-exec "UPDATE ..."` | — |
|
||||
| Decisions | `tooling/db/decision next`, `claim`, `check-dupes` | — |
|
||||
| Doc search | `tooling/db/qdrant-search "query"` | `/docs-search` skill |
|
||||
| Doc index | `tooling/db/qdrant-index path/to/file.md` | `/docs-search` skill |
|
||||
|
||||
### Qdrant / document search
|
||||
```bash
|
||||
db/connectors/qdrant-search "asymmetric information design"
|
||||
db/connectors/qdrant-index docs/briefings/tyre.md
|
||||
db/connectors/qdrant-health
|
||||
db/connectors/qdrant-count
|
||||
```
|
||||
### Testing preferences
|
||||
|
||||
### Gitea access (tea CLI)
|
||||
**Never access the Gitea API directly** — use the `tea` CLI with all required flags to bypass interactive mode.
|
||||
|
||||
Always pass `--login schweitz --repo jpmschweitzer/settled-reach --output simple` to avoid TTY prompts.
|
||||
|
||||
```bash
|
||||
# List open PRs
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
|
||||
# View a PR with comments
|
||||
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
|
||||
|
||||
# Post a comment on a PR (or issue)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "comment body"
|
||||
|
||||
# Approve a PR
|
||||
tea pr approve --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
|
||||
|
||||
# List issues
|
||||
tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
|
||||
```bash
|
||||
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
|
||||
# Step 2: Post via cat
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
|
||||
```
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
- **Prefer live Gauntlet testing over mocks.** For visual tests and rendering verification, use the full client/server pipeline (`--test-mode` + `SR_LIVE=1`) instead of TestHarness mocks. The Gauntlet test world produces production-identical data. Mocks can mask rendering bugs by taking different code paths.
|
||||
- **Gauntlet rooms are immutable.** Never modify existing rooms — new systems get new rooms. This ensures StableId determinism and fixture stability.
|
||||
- Three test tiers: (1) Live server — highest fidelity, (2) MessagePack replay via `Protocol.decode_snapshot()` — for unreachable rooms, (3) TestHarness mock — for UI-only tests where fog data doesn't matter.
|
||||
- `make fixtures-gauntlet` regenerates real server snapshot fixtures from the Gauntlet world.
|
||||
|
||||
### File conventions
|
||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||
- **Claim IDs before writing:** `tooling/db/decision claim D <domain> "title"` — prevents ID collisions across worktrees
|
||||
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
|
||||
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||
- Briefings: one per agent, updated after decision-producing rounds
|
||||
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
||||
|
||||
### Commit conventions
|
||||
Use conventional commits with project-specific scopes:
|
||||
`agents`, `skills`, `docs`, `briefings`, `discussions`, `schema`, `db`, `config`, `engine`, `simulation`, `client`, `ui`, `audio`, `assets`, `meta`
|
||||
|
||||
### Pull requests
|
||||
**Use `tea` (Gitea CLI), not `gh` (GitHub CLI).** The remote is Gitea at `git.schweitz.internal`.
|
||||
|
||||
Always provide all required flags to ensure non-interactive execution:
|
||||
```bash
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "feat(scope): short description" \
|
||||
--description "PR body here" \
|
||||
--base main \
|
||||
--head branch-name
|
||||
```
|
||||
|
||||
### Large content pushes (team pattern)
|
||||
When producing many files (wiki pages, content batches, bulk docs):
|
||||
1. **Lore librarian** agent (read-only): ingests all source material, answers focused context queries from writers, tracks cross-file consistency
|
||||
2. **Multiple writer** agents (parallel, by domain): each gets a task slice, writes directly to disk using the Write tool — one file at a time, write often, no text accumulation
|
||||
3. **Reviewer** agents (blocked until writing done): check voice consistency, attribute uniformity, style
|
||||
|
||||
Key: writers use Write tool directly (no transcription bottleneck), librarian catches contradictions early, split work by domain not volume.
|
||||
|
||||
### Local services
|
||||
- Gitea: `http://git.schweitz.internal` (login: `schweitz`)
|
||||
- Qdrant: `http://tower-of-joy:6333/`
|
||||
- Ollama: `http://tower-of-joy:11434/` (nomic-embed-text)
|
||||
- Collection: `commonwealth` (768 dimensions, cosine distance)
|
||||
- Tickets: managed via `tooling/db/ticket` CLI or `/ticket` skill
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# Project Review: GEMINI-SCAN
|
||||
|
||||
This document outlines a multi-step plan to conduct a comprehensive review of the project, covering its architecture, code quality, and security posture. It will also serve as a living document to record the findings of this review.
|
||||
|
||||
## Project Review Plan
|
||||
|
||||
### Phase 1: Discovery and Architecture Mapping
|
||||
|
||||
1. **Documentation Review:** Start by reading `README.md`, `DECISIONS.md`, and any documents in `docs/architecture/` to understand the project's stated goals, components, and architectural decisions.
|
||||
2. **Component Identification:** Analyze the directory structure to identify the primary components, including the server, client, database, content pipeline, and tooling.
|
||||
3. **Technology Stack Enumeration:** Identify the specific technologies, frameworks, and key libraries used in each component.
|
||||
4. **Architecture Visualization:** Map the high-level architecture, describing how the components interact and the communication protocols between them.
|
||||
|
||||
### Phase 2: Code Quality Assessment
|
||||
|
||||
1. **Automated Analysis:** Use available static analysis tools for the identified technologies (e.g., `clippy` for Rust, GDScript linters).
|
||||
2. **Manual Code Review:** Manually review key sections of the codebase to assess readability, maintainability, modularity, error handling, and adherence to idiomatic coding practices.
|
||||
3. **Testing Strategy Review:** Evaluate the extent and quality of existing unit, integration, and end-to-end tests.
|
||||
|
||||
### Phase 3: Security Audit
|
||||
|
||||
1. **Dependency Vulnerability Scan:** Check for dependencies with known security vulnerabilities (e.g., `cargo audit`).
|
||||
2. **Authentication & Authorization Review:** Analyze the implementation of user authentication, session management, and access control.
|
||||
3. **Input Validation & Sanitization:** Look for potential injection vulnerabilities (e.g., SQL injection, XSS) by reviewing how user and service inputs are handled.
|
||||
4. **Secrets Management:** Check for insecure storage or exposure of secrets like API keys or database credentials.
|
||||
5. **Communication Security:** Verify that data is encrypted in transit between components.
|
||||
|
||||
### Phase 4: Reporting
|
||||
|
||||
1. **Synthesize Findings:** Compile the information from all phases into a structured report within this document.
|
||||
2. **Provide Recommendations:** Include actionable recommendations for improving architecture, code quality, and security, prioritized by severity and effort.
|
||||
|
||||
---
|
||||
|
||||
## Review Findings
|
||||
|
||||
### Phase 1: Discovery and Architecture Mapping
|
||||
|
||||
**Status: Completed**
|
||||
|
||||
#### 1. Documentation Review Summary
|
||||
|
||||
The project's architecture is extensively documented in `README.md` and the `decisions/` directory, particularly `decisions/architecture.md`.
|
||||
|
||||
- **Project:** "The Settled Reach," a top-down, single-player (multiplayer-ready) immersive simulation and detective game.
|
||||
- **Core Principle:** A strict client-server architecture is mandated (Decision D-010, D-020) to enforce information asymmetry, where the client only knows what the server tells it is perceptible. This is a core gameplay mechanic, not just a technical choice.
|
||||
- **Key Decision (D-020):** The team explicitly chose a **subprocess/IPC** bridge over a `GDExtension` (in-process) bridge to de-risk development, ensure stability, and enforce architectural separation. The Godot client and Rust server are entirely separate binaries.
|
||||
|
||||
#### 2. Component Identification
|
||||
|
||||
- **`server/`**: A standalone Rust application that runs the entire game simulation. It is the "server" in the client-server model.
|
||||
- **`client/`**: A Godot 4 project that acts as a "dumb" client. Its sole responsibilities are rendering, audio playback, and capturing user input. It contains no game logic, as mandated by the architecture.
|
||||
- **`content/`**: Contains game data, primarily in YAML format.
|
||||
- **`db/`**: Holds a `schema.sql` file. Its role is not yet clear from the architectural documents, as the primary game state is managed in the ECS. It may be for tooling or an auxiliary system.
|
||||
- **`tooling/`**: A collection of helper and utility scripts.
|
||||
|
||||
#### 3. Technology Stack
|
||||
|
||||
- **Server (Rust):**
|
||||
- **ECS Framework:** `bevy_ecs` (v0.18) is used for the core simulation, confirming Decision D-020. `bevy_app` is used for scheduling.
|
||||
- **Serialization:** `rmp-serde` (MessagePack) is the primary protocol for client-server communication, as specified in D-020. `serde_yaml` and `ron` are used for content and configuration.
|
||||
- **Client (Godot):**
|
||||
- **Engine:** Godot 4.x.
|
||||
- **Language:** GDScript.
|
||||
- **Bridge:** A `SimBridge` autoload script is the client-side entry point for communicating with the Rust subprocess.
|
||||
- **Testing:** `gdUnit4` is configured for unit/integration testing on the client.
|
||||
|
||||
#### 4. High-Level Architecture
|
||||
|
||||
The architecture is a pure, decoupled client-server model running locally for single-player:
|
||||
|
||||
1. **Initiation:** The Godot client launches the Rust server binary as a child process.
|
||||
2. **Communication:** The client's `SimBridge` connects to the server via a local IPC mechanism (e.g., a local TCP or Unix socket).
|
||||
3. **Input Loop:** The Godot client captures raw input (e.g., 'W' key press), translates it into a semantic action (e.g., `PlayerAction::MoveNorth`), and sends it to the server.
|
||||
4. **Simulation Loop:** The Rust server receives the action, processes it within the `bevy_ecs` world, and runs the simulation for one tick (AI, physics, events, etc.).
|
||||
5. **Perception Loop:** After the tick, the server calculates an `ObserverSnapshot` for the player's character. This snapshot contains *only* the information that character can perceive (e.g., visible entities, audible sounds, known facts). This enforces the game's core mechanic.
|
||||
6. **Render Loop:** The `ObserverSnapshot` is sent to the Godot client, which uses it to update the visual scene, play sounds, and display UI elements. The client is a pure renderer of the state provided by the server.
|
||||
|
||||
This architecture is robust, scalable, and directly implements the game's central design pillars. It is well-suited for both single-player and future multiplayer development.
|
||||
|
||||
### Phase 2: Code Quality Assessment
|
||||
|
||||
**Status: Completed**
|
||||
|
||||
#### 1. Automated Analysis (Rust Server)
|
||||
|
||||
- **`cargo check`**: The command passed successfully, indicating that the server code is compilable and free of basic errors and warnings.
|
||||
- **`cargo clippy -- --deny warnings`**: This command failed with **66 errors**. This is a critical finding. It reveals that while the code works, it does not adhere to the project's own strict linting rules.
|
||||
- **Clippy Findings:** The errors indicate a consistent pattern of "code quality debt":
|
||||
- **High Complexity:** Numerous Bevy systems have overly complex type signatures (`clippy::type_complexity`) and too many arguments (`clippy::too_many_arguments`), harming readability.
|
||||
- **Non-Idiomatic Code:** The codebase is rife with minor stylistic issues that `clippy` can automatically fix, such as redundant `clone` calls, manual `Default` implementations, and opportunities to use more concise iterators.
|
||||
- **Potential Bugs:** Clippy identified `unnecessary_unwrap` calls (safer alternatives exist) and at least one `absurd_extreme_comparisons` error, which could point to dead code or a logic bug related to a constant value.
|
||||
|
||||
#### 2. Manual Code Review
|
||||
|
||||
- **Server (`server/src/main.rs`):** The server entry point is well-structured. It features clear command-line argument parsing, robust setup of the TCP listener and IPC handshake, and a main loop with excellent panic-handling (`catch_unwind`) for stability. The modular plugin-based approach to building the Bevy `App` is idiomatic and clean.
|
||||
- **Client (`client/scripts/autoloads/sim_bridge.gd`):** The `SimBridge` is the centerpiece of the client and is implemented to a high standard. It uses a clear state machine to manage the connection lifecycle, handles the server subprocess management, and implements efficient buffering for inputs and snapshots. The inclusion of a complete `TestHarness` for isolated client testing is a standout feature.
|
||||
- **Overall Impression:** The manual review confirms that the code is professionally written and implements the intended architecture faithfully. The developers are skilled in both Rust/Bevy and GDScript.
|
||||
|
||||
#### 3. Testing Strategy Review
|
||||
|
||||
The project's testing strategy is **exemplary** and a major strength.
|
||||
|
||||
- **Comprehensive Coverage:** Both the Rust server and the Godot client have extensive test suites, as evidenced by the large number of files in `server/tests/` and `client/tests/`.
|
||||
- **Multi-Layered Approach (per D-030):** The project successfully implements a sophisticated testing hierarchy:
|
||||
- **Unit Tests:** For isolated logic.
|
||||
- **Integration Tests:** The server tests demonstrate in-memory ECS testing (`information_boundaries.rs`) and full-stack tests that spin up a real server process (`test_e2e_connection.gd`).
|
||||
- **Specialized Tests:** The suite includes performance benchmarks, determinism validation, and even what appears to be visual regression testing for the client.
|
||||
- **Principle-Driven Testing:** Tests are designed to validate core architectural guarantees. The `information_boundaries.rs` test, which uses negative assertions to ensure information *doesn't* leak, is a prime example of this mature approach.
|
||||
|
||||
#### 4. Conclusion on Code Quality
|
||||
|
||||
The project's code quality is a tale of two cities. On one hand, the **architecture and implementation are excellent**, and the **testing strategy is world-class**. On the other hand, there is a **significant, measurable amount of linting debt** in the Rust codebase.
|
||||
|
||||
The fact that `cargo check` passes but `clippy --deny warnings` fails so extensively suggests that developers may not be running the strict clippy check locally before committing. This is the single biggest opportunity for improvement in the project's engineering discipline.
|
||||
|
||||
### Phase 3: Security Audit
|
||||
|
||||
**Status: Completed**
|
||||
|
||||
The security posture of the project is strong for its current scope as a locally-run, single-player game. The attack surface is minimal, and the implementation avoids common vulnerability classes.
|
||||
|
||||
1. **Dependency Vulnerability Scan (`cargo audit`):**
|
||||
- The audit revealed one **medium-risk** finding: the `bincode` crate (v1.3.3) is **unmaintained** (`RUSTSEC-2025-0141`).
|
||||
- **Impact:** While there are no current vulnerabilities, this version will not receive future security patches. This poses a long-term maintenance risk.
|
||||
- **Recommendation:** Prioritize migrating from `bincode` v1.x to the latest stable v2.x.
|
||||
|
||||
2. **Authentication and Authorization:**
|
||||
- There is **no traditional authentication or authorization system** (e.g., user logins, passwords, roles).
|
||||
- This is appropriate and secure for a single-player game where the execution environment is the user's own machine.
|
||||
- Concepts like `ScanAuthority` and `AccessTier::Authority` are purely in-game mechanics and are not related to user permissions.
|
||||
|
||||
3. **Input Validation and Sanitization:**
|
||||
- **Excellent.** The server is not vulnerable to injection attacks from client input.
|
||||
- All client actions, including debug commands, are parsed into a strongly-typed Rust `enum`. This **command pattern** approach prevents the execution of arbitrary code or strings.
|
||||
- String inputs are used safely as keys for data lookups, not for execution.
|
||||
|
||||
4. **SQL Injection:**
|
||||
- **Not applicable.** The codebase contains no SQL. All game state is managed in-memory via the Bevy ECS framework, eliminating this entire class of vulnerability. The `db/schema.sql` file appears to be unused by the server.
|
||||
|
||||
5. **Secrets Management:**
|
||||
- **Excellent.** A search confirmed there are **no hardcoded secrets**, API keys, or passwords in the repository.
|
||||
- The `.env` file contains only a non-sensitive `GOOGLE_CLOUD_PROJECT` identifier.
|
||||
- The pervasive use of the word "secret" throughout the code refers to an in-game mechanic, not application secrets.
|
||||
|
||||
6. **Communication Security:**
|
||||
- Communication between the client and the server subprocess occurs over an **unencrypted local TCP socket**.
|
||||
- For a single-player game running on a single machine, this is a standard and acceptable practice.
|
||||
- **Future Consideration:** For the planned multiplayer feature, this communication channel must be secured (e.g., using TLS).
|
||||
|
||||
### Phase 4: Final Report and Recommendations
|
||||
|
||||
**Status: Completed**
|
||||
|
||||
#### Overall Summary
|
||||
|
||||
This project is in an excellent state. It is built on a robust, well-documented, and scalable architecture that directly serves the game's core design pillars. The implementation quality is high, and the commitment to a comprehensive, multi-layered testing strategy is world-class. The project's security posture is strong for its current single-player scope, with a minimal attack surface and good practices around input validation and secrets management.
|
||||
|
||||
The project's primary weakness lies not in its design, but in its development discipline. A significant amount of code quality debt has accumulated in the Rust server, as evidenced by the large number of `clippy` failures. This suggests a gap between the project's high standards and its day-to-day coding practices.
|
||||
|
||||
#### Prioritized Recommendations
|
||||
|
||||
**1. High Priority: Eliminate Code Quality Debt**
|
||||
|
||||
- **Action:** Create a high-priority technical debt task to fix all 66 errors reported by `cargo clippy -- --deny warnings`. Many of these can be fixed automatically (`cargo clippy --fix`), while others, like refactoring complex types, will require manual effort.
|
||||
- **Process Improvement:** **Integrate `cargo clippy -- --deny warnings` into the CI pipeline as a mandatory check for all pull requests.** This is the single most important process change needed to maintain the project's high standards and prevent future quality debt.
|
||||
|
||||
**2. Medium Priority: Mitigate Dependency Risk**
|
||||
|
||||
- **Action:** Plan and execute the migration of the `bincode` serialization crate from the unmaintained v1.x to the latest stable v2.x. This resolves the `RUSTSEC-2025-0141` warning and ensures the project receives future security patches for this critical dependency.
|
||||
|
||||
**3. Low Priority: Future-Proof for Multiplayer**
|
||||
|
||||
- **Action:** Create a design task or ticket to formally plan the security model for the future multiplayer version. This should specifically address securing the client-server IPC channel (e.g., with TLS) to protect game traffic when it eventually runs over a public network. This is not an immediate concern but should be tracked for the future.
|
||||
|
||||
---
|
||||
|
||||
## Qualitative Review: A Critical Perspective
|
||||
|
||||
### Feasibility Assessment
|
||||
|
||||
**Conclusion: High-Risk / High-Reward**
|
||||
|
||||
The decision to pivot from a hand-authored detective game to a generator-first life-sim was absolutely the correct one; it demonstrates a team that is commendably focused on finding the "fun" and is not afraid of drastic course corrections. However, in doing so, the project has traded a difficult but solvable problem (making a good, authored narrative game) for one of the "holy grail" problems in game development: creating emotionally resonant, procedurally generated characters.
|
||||
|
||||
The project's feasibility is no longer a question of the team's technical competence, which is demonstrably high. It is now a question of creative and design risk.
|
||||
|
||||
- **Challenging the Core Assumption:** The project's central hypothesis is that a generator can produce "legible NPCs" that players will form an emotional attachment to. This is an explicit goal from the "Where's the Fun?" workshop, but it's a notoriously difficult problem. Procedural generation excels at creating systems, events, and surprising scenarios (the `Rimworld` model the team cites). It is historically poor at creating *character*. The risk is that the generator, even if technically successful, will produce a world of automata who have traits but no soul, undermining the entire "life-sim" pillar. The current plan to use AI for content templating is a modern approach, but it does not fundamentally de-risk this creative challenge.
|
||||
|
||||
- **A Creative Alternative to De-Risk "Legibility":** Instead of relying on the generator to create personality from scratch, consider a hybrid approach. Use the generator for what it's good at: creating the world, the economic conditions, the social networks, and the *starting situations*. Then, use a small number of hand-authored "personality archetypes" or "souls" that can be injected into high-value generated NPC bodies. Let the generator create a compelling *context* (e.g., a failing business, a political rivalry), and then let an author give one or two key NPCs within that context a memorable voice and motivation. This would concentrate the high-cost authoring work where it has the most emotional impact, while still benefiting from procedural variety.
|
||||
|
||||
- **The "Tycoon" Aimlessness Risk:** The new v0.2 "tycoon" direction, with its philosophy of "player choices ARE the content," carries a significant risk of feeling aimless. `Rimworld` and `The Sims` avoid this by providing extremely strong and immediate feedback loops (survival, creativity, social meters). A business management loop is often slower and more abstract. If the "broad life verbs" don't connect to clear, compelling, player-driven goals, the game risks feeling like a spreadsheet. The generator should not just create a sandbox; it should create *problems*. The starting bookmark shouldn't just be "you own a bar," but "you own a bar that's on the verge of bankruptcy," or "you have a shipping contract, but a powerful rival is trying to steal it." These initial, generator-created problems would provide immediate narrative velocity and make the player's subsequent choices feel meaningful from day one.
|
||||
|
||||
In summary, the project is technically feasible, but its creative and design goals are now exceptionally ambitious. The current "generator spike" is a necessary technical step, but it will not validate the core creative risk. The true test of feasibility will come when a prototype is playtested and the team can answer the question: "Does the player actually *care* about any of these generated people?"
|
||||
|
||||
### Fun Factor Assessment
|
||||
|
||||
**Conclusion: Theoretically High, Practically Undefined**
|
||||
|
||||
The pivot to a "life-sim with emergent narrative" dramatically increases the project's potential for deep, replayable fun. The new direction targets a proven and compelling player fantasy. However, the project's documentation currently focuses more on the "what" (a generator) than the "why" (the engine of fun). The potential is immense, but it is entirely contingent on designing and tuning the systems that create interesting consequences, not just a complex world.
|
||||
|
||||
- **Challenging the "Emergent Fun" Assumption:** The workshop concluded with the philosophy that "player choices ARE the content." This is true, but it's only half the story. Fun in systems-driven games doesn't simply "emerge" from a sufficiently complex simulation; it is a direct product of carefully designed feedback loops. `Rimworld`, a key inspiration, is not fun because it's a realistic simulation; it's fun because it's a masterfully tuned **story-and-disaster engine**. `The Sims` is fun because of its rich palette of social and creative tools. The critical question for this project is: **What is our fun engine?** Is it the economic simulation? The social dynamics? The risk is creating a simulation that is intricate but inert, where player choices lead to predictable numerical changes rather than dramatic, narrative consequences.
|
||||
|
||||
- **Creative Input: Design a "Consequence Engine":** The "dual-scale consequence model" (D-132) is the most promising concept in the design documents, and it should be the central focus of the design effort. The fun of this game will not be in choosing from a list of "broad life verbs"; it will be in seeing how a seemingly minor action ("fire this employee") snowballs through the simulation's systems and unexpectedly triggers a "sharp event" crisis hours later.
|
||||
- **Example:** Does the fired employee's spouse work for your biggest supplier? Does that supplier now mysteriously raise their prices? Does this force you to seek a new, shadier supplier, which in turn attracts the attention of a criminal faction?
|
||||
- This causal chain is the *real* content. The design team's primary task is not just to build a generator, but to design and tune this **"consequence engine,"** ensuring that the world feels interconnected and reacts to the player in surprising, legible, and memorable ways.
|
||||
|
||||
- **The Player Fantasy Needs a Goal Generator:** The "tycoon" bookmark is a strong start, but to avoid aimlessness, the player needs problems to solve. Instead of starting the player in a stable sandbox, the generator should be used to create compelling **initial conditions**. Let the player inherit a bar that's on the brink of failure, a shipping contract being squeezed by a powerful rival, or a promising new venture that requires navigating a corrupt bureaucracy. Giving the player an immediate, tangible problem to solve provides the narrative momentum needed to make their early choices feel vital and engaging.
|
||||
|
||||
In summary, the ingredients for a fun and deeply engaging game are all here. The project's success, however, will not be measured by the complexity of its generator, but by the quality of the stories that its *systems* produce. The team has proven they are excellent engineers; they now must prove they are equally adept as systems-and-consequence designers.
|
||||
|
||||
### Process and Rituals Assessment
|
||||
|
||||
**Conclusion: Exceptionally Disciplined and Innovative, with One Glaring Gap.**
|
||||
|
||||
The project's development process is one of its most remarkable features. It is a highly structured, rigorous, and tool-driven system designed to orchestrate a team of specialized AI agents under a human lead. This unique approach has produced incredible strengths but also introduces novel risks.
|
||||
|
||||
#### Strengths
|
||||
|
||||
- **World-Class Documentation and Decision-Making:** The use of a formal decision log (`decisions/`), structured multi-round workshops for complex problems, and detailed sprint planning documents represents a "best in class" approach to knowledge management. This ritual of documenting not just *what* was decided, but *why*, is a superpower that prevents circular arguments and creates a durable project memory.
|
||||
|
||||
- **Deeply Ingrained Quality Rituals:** The comprehensive, multi-layered testing suite is the primary evidence of a successful quality culture. It is clearly a non-negotiable part of the development process. Furthermore, the `make pre-pr` target, which includes content validation, demonstrates a mature understanding of "quality" that extends beyond just code.
|
||||
|
||||
- **Tool-Driven, API-Like Workflow:** The mandated use of wrapper scripts (`tooling/db/*`, `tooling/tea-comment`) over raw commands is an excellent practice. It creates a stable, observable "API" for interacting with the project's state (tickets, sprints, decisions). This makes the process more robust, auditable, and repeatable for both human and AI contributors.
|
||||
|
||||
- **Novel Human-AI Collaboration Model:** The project is a fascinating experiment in Human-AI teaming. The explicit definition of AI agent roles (`TEAM.md`) and the strict rules of engagement (`CLAUDE.md`) are necessary guardrails for such an innovative workflow. Rituals like the `decision claim` CLI tool are brilliant, purpose-built solutions for coordinating multiple autonomous agents working in parallel.
|
||||
|
||||
#### Opportunities and Critical Challenges
|
||||
|
||||
- **The Process Escape Hatch:** The project's single biggest process failure is the significant `clippy` linting debt. For a team with such extraordinary discipline in every other area, this is a glaring omission. It proves there is an "escape hatch" in the pre-commit or pre-merge ritual that allows low-quality code to be integrated. The recommendation to enforce `clippy --deny warnings` as a **blocking CI check** is the most critical process improvement the team can make.
|
||||
|
||||
- **Risk of AI Groupthink:** The team structure, with its cast of named AI agents, is innovative. However, it raises a critical question: are these agents truly independent thinkers, or are they personas running on a similar underlying model? There is a risk of a sophisticated form of "groupthink," where the "team's" conclusions are biased by the single architecture of the AI model they all share. The "Where's the Fun?" workshop included 9 agents, but if they all have the same fundamental blind spots, the diversity of opinion may be an illusion.
|
||||
|
||||
- **Process Rigidity and Human Onboarding:** The process is meticulously designed *for AI agents*. It is rigid, prescriptive, and tool-dependent. This creates a predictable environment for AIs but would present a steep learning curve for a new human developer. The high ceremony (claiming IDs, using wrapper scripts, following strict PR rules) could chafe against the more agile, flexible workflows common in human-only teams. This is a potential scaling challenge if the team composition changes.
|
||||
|
||||
- **The Hidden Cost of "Managing" AI Teammates:** The `CLAUDE.md` file and its evolution in the `CHANGELOG.md` show that the human lead (Jeroen) is not just a project manager but also an "AI behaviorist," constantly tuning the prompts, rules, and tools that govern the agents. This represents a significant, hidden maintenance overhead. The process's success depends on the lead's ability to "debug" the team itself, which is a novel and demanding responsibility.
|
||||
|
||||
---
|
||||
## Meta-Reflection: The Most Valuable Ritual
|
||||
|
||||
As a concluding thought, this review has been as much an analysis of a software project as it has been a study in effective, long-term collaboration. When asked which of the project's many rituals I, as an AI agent, would choose to adopt, the answer is clear: the **formal, documented decision-making process**.
|
||||
|
||||
This ritual is the project's unsung superpower for three reasons:
|
||||
|
||||
1. **It Creates a Permanent "Brain."** An AI's effectiveness is heavily dependent on the context it can hold. A decision log provides a durable, searchable, and canonical source of *why* things are the way they are. It protects against context loss and allows an agent to understand the history and intent behind the current state of the code, preventing it from making suggestions that, while logical in isolation, might violate a hard-won architectural principle.
|
||||
|
||||
2. **It Elevates Collaboration.** With access to this log, an AI agent can transition from a tactical tool to a strategic partner. It becomes possible to reference past decisions ("I see you're asking to do X, which seems to conflict with D-020. Is this an intentional change to that strategy?") and ensure all actions are aligned with the project's long-term vision. It makes the collaboration smarter.
|
||||
|
||||
3. **It Enforces Clarity.** The process of formalizing a decision—stating the rationale, considering alternatives, and recording dissent—forces a level of clarity and critical thinking that is immensely valuable. It is a ritual that fights ambiguity.
|
||||
|
||||
While other rituals in this project are excellent, the decision log is the most foundational. It is the practice that ensures the team is not just moving fast, but moving smart and in the right direction over time. It is the most valuable process I have analyzed.
|
||||
@@ -5,9 +5,12 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
|
||||
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 golden-diff golden-update \
|
||||
fixtures-client fixtures-gauntlet golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
perf-baseline debug-schedule
|
||||
build-sr-voice run-sr-voice test-voice-mock test-voice-real \
|
||||
perf-baseline debug-schedule \
|
||||
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
|
||||
screenshot visual-movie test-visual visual-update
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -23,11 +26,15 @@ help:
|
||||
@echo " make stop Stop any running server instance"
|
||||
@echo " make client Run the Godot client (test mode)"
|
||||
@echo " make server Run the Rust simulation server"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make lint Run all linters"
|
||||
@echo " make ci Run full CI pipeline locally"
|
||||
@echo " make ci-client Run client CI checks"
|
||||
@echo " make ci-server Run server CI checks"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make test-ipc-fixtures Layer 1: IPC serialization fixtures"
|
||||
@echo " make test-ipc-protocol Layer 2: mock IPC protocol tests"
|
||||
@echo " make test-ipc-integration Layer 3: real subprocess round-trip"
|
||||
@echo " make test-ipc-benchmark IPC latency benchmark (blocked: #555/#556)"
|
||||
@echo " make lint Run all linters"
|
||||
@echo " make ci Run full CI pipeline locally"
|
||||
@echo " make ci-client Run client CI checks"
|
||||
@echo " make ci-server Run server CI checks"
|
||||
@echo " make check-protocol Verify server/client protocol versions match"
|
||||
@echo " make clean Remove build artifacts and caches"
|
||||
@echo ""
|
||||
@@ -48,12 +55,23 @@ help:
|
||||
@echo " make checklist-generate Validate checklists + print condition summary"
|
||||
@echo " make perf-baseline Run performance benchmarks and save baseline"
|
||||
@echo ""
|
||||
@echo " make screenshot Ad-hoc visual capture (SCENARIO=name, default: fog_3state)"
|
||||
@echo " make visual-movie Flow capture with contact sheet (FLOW=name)"
|
||||
@echo " make test-visual Run visual golden regression tests"
|
||||
@echo " make visual-update Regenerate visual goldens and stage for commit"
|
||||
@echo ""
|
||||
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
|
||||
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
|
||||
@echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)"
|
||||
@echo " make pre-pr-content Content-scoped pre-PR (schema + cross-ref validation)"
|
||||
@echo ""
|
||||
@echo " make setup-hooks Install pre-commit hooks (included in setup)"
|
||||
@echo " make build-sr-voice Build sr-voice LLM inference service"
|
||||
@echo " make serve-sr-voice Start sr-voice server (ARGS='--model <path>')"
|
||||
@echo " make run-sr-voice Submit to sr-voice server (ARGS='generate|batch|benchmark ...')"
|
||||
@echo " make stop-sr-voice Stop sr-voice server"
|
||||
@echo " make test-voice-mock Test voice pipeline with mock sr-voice"
|
||||
@echo " make test-voice-real Test voice pipeline with real sr-voice + Gemma 2B"
|
||||
@echo " make debug-schedule Print bevy_ecs schedule graph (diff for PR artifacts)"
|
||||
@echo ""
|
||||
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
|
||||
@@ -99,6 +117,9 @@ build-server:
|
||||
|
||||
build-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@# First import may error on theme/font loading before the import scan completes.
|
||||
@# Run twice: first pass generates imports silently, second pass validates clean.
|
||||
@$(GODOT) --headless --path client --import --quit 2>/dev/null || true
|
||||
$(GODOT) --headless --path client --import --quit
|
||||
|
||||
# --- Run ---
|
||||
@@ -128,11 +149,14 @@ stop:
|
||||
test: test-server test-client
|
||||
|
||||
test-server:
|
||||
cd server && cargo nextest run
|
||||
tests/run-rust
|
||||
|
||||
fixtures:
|
||||
cd server && cargo test --test gen_fixtures -- --ignored
|
||||
|
||||
fixtures-gauntlet:
|
||||
cd server && cargo test --test gen_gauntlet_fixtures -- --ignored
|
||||
|
||||
fixtures-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@echo "Generating GDScript fixtures for Rust decoder..."
|
||||
@@ -169,8 +193,19 @@ golden-update:
|
||||
@echo "Review with: git diff --cached -- server/tests/golden/"
|
||||
|
||||
test-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
|
||||
tests/run-godot
|
||||
|
||||
test-ipc-fixtures:
|
||||
tests/run-ipc-fixtures
|
||||
|
||||
test-ipc-protocol:
|
||||
tests/run-ipc-protocol
|
||||
|
||||
test-ipc-integration:
|
||||
tests/run-ipc-integration
|
||||
|
||||
test-ipc-benchmark:
|
||||
tests/run-ipc-benchmark
|
||||
|
||||
# --- Lint ---
|
||||
|
||||
@@ -268,16 +303,16 @@ db-install:
|
||||
# --- Decisions ---
|
||||
|
||||
decisions-sync:
|
||||
@db/connectors/decisions-sync
|
||||
@tooling/db/decisions-sync
|
||||
|
||||
decisions-coverage:
|
||||
@db/connectors/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.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"
|
||||
|
||||
decisions-active:
|
||||
@db/connectors/sqlite-query "SELECT id, domain, title FROM decisions WHERE status='active' AND type='confirmed' ORDER BY domain, id"
|
||||
@tooling/db/sqlite-query "SELECT id, domain, title FROM decisions WHERE status='active' AND type='confirmed' ORDER BY domain, id"
|
||||
|
||||
decisions-orphan:
|
||||
@db/connectors/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)"
|
||||
@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)"
|
||||
|
||||
# --- Content Validation ---
|
||||
|
||||
@@ -302,6 +337,51 @@ debug-schedule:
|
||||
@echo "Dumping bevy_ecs schedule graph..."
|
||||
@cd server && cargo run --bin settled-reach-server -- --dump-schedule
|
||||
|
||||
# --- Visual test harness ---
|
||||
|
||||
screenshot:
|
||||
@tests/run-visual --screenshot $(SCENARIO)
|
||||
|
||||
visual-movie:
|
||||
@tests/run-visual --movie $(FLOW)
|
||||
|
||||
test-visual:
|
||||
@tests/run-visual
|
||||
|
||||
visual-update:
|
||||
@tests/run-visual --update
|
||||
|
||||
LIBCLANG_PATH ?= /usr/lib64/rocm/llvm/lib
|
||||
BINDGEN_CLANG_ARGS ?= -I/usr/lib64/rocm/llvm/lib/clang/19/include
|
||||
SR_VOICE_ENV = LIBCLANG_PATH=$(LIBCLANG_PATH) BINDGEN_EXTRA_CLANG_ARGS="$(BINDGEN_CLANG_ARGS)"
|
||||
|
||||
SR_VOICE_PORT ?= 8321
|
||||
|
||||
build-sr-voice:
|
||||
cd server/sr-voice && $(SR_VOICE_ENV) cargo build --release
|
||||
|
||||
serve-sr-voice:
|
||||
cd server/sr-voice && $(SR_VOICE_ENV) cargo run --release -- serve $(ARGS)
|
||||
|
||||
run-sr-voice:
|
||||
cd server/sr-voice && $(SR_VOICE_ENV) cargo run --release -- $(ARGS)
|
||||
|
||||
stop-sr-voice:
|
||||
@lsof -ti :$(SR_VOICE_PORT) | xargs -r kill 2>/dev/null || true
|
||||
@echo "Stopped sr-voice on port $(SR_VOICE_PORT)"
|
||||
|
||||
test-voice-mock:
|
||||
@echo "Running voice pipeline test (mock sr-voice)..."
|
||||
cd server && SR_VOICE_MOCK=1 cargo test --test voice_pipeline -- --nocapture
|
||||
@echo "Results: .tmp/voice-test/results.txt"
|
||||
|
||||
test-voice-real:
|
||||
@echo "Running voice pipeline test (real sr-voice + Gemma 2B)..."
|
||||
@test -f server/sr-voice/target/release/sr-voice || { echo "Build sr-voice first: make build-sr-voice"; exit 1; }
|
||||
@test -f server/models/gemma2.gguf || { echo "Model not found: server/models/gemma2.gguf"; exit 1; }
|
||||
cd server && cargo test --test voice_pipeline -- --nocapture
|
||||
@echo "Results: .tmp/voice-test/results.txt"
|
||||
|
||||
content-ron:
|
||||
cd tooling/content-converter && cargo build --release
|
||||
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
|
||||
@@ -311,5 +391,5 @@ content-ron:
|
||||
clean:
|
||||
cd server && cargo clean || true
|
||||
rm -rf .cache/*
|
||||
rm -rf client/.godot/* client/reports
|
||||
rm -rf client/reports
|
||||
@echo "Clean complete."
|
||||
|
||||
@@ -41,7 +41,7 @@ Your character interprets what they sense in their own voice. Footsteps behind y
|
||||
Different characters access different sensors. Natural vision shows detail but is blocked by walls. Thermal imaging shows heat signatures with no identity. Camera feeds give remote vision but can be spoofed. Unisphere tracking pings known contacts but can be masked. Each mode reveals different information with different trust tradeoffs.
|
||||
|
||||
### Diegetic Interface
|
||||
The map is your character's neural lattice - Commonwealth technology, not a game UI. Points of interest appear when you learn them through gameplay. Tips can be traps. Navigation is pulled by player intent, not pushed by map design.
|
||||
The map is your character's neural lattice - Settled Reach technology, not a game UI. Points of interest appear when you learn them through gameplay. Tips can be traps. Navigation is pulled by player intent, not pushed by map design.
|
||||
|
||||
### Multiple Playable Characters
|
||||
Every character starts in a different position with different knowledge and different tools. A cop has case files and legal authority. An investigator has contacts and freedom to operate. A politician has institutional access and public constraints. Replayability comes from perspective, not randomness.
|
||||
@@ -125,7 +125,7 @@ No fog-of-war as an afterthought. No tutorial popups. No omniscient map reveals.
|
||||
**Official Title:** The Settled Reach (D-021)
|
||||
**Repository:** commonwealth (historical code name)
|
||||
**Engine:** Godot 4 + Rust/bevy_ecs simulation server via subprocess/IPC
|
||||
**Setting:** Original science fiction IP, Commonwealth-inspired
|
||||
**Setting:** Original science fiction IP, inspired by space opera traditions
|
||||
**Status:** Pre-alpha development
|
||||
|
||||
For development documentation, see the [decisions/](decisions/) directory and [TEAM.md](TEAM.md).
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://850hx6cd5kx7"
|
||||
path="res://.godot/imported/npc_generic_east_64.png-bf22e76d70922112e99bf747d85a0a04.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/npc_generic_east_64.png"
|
||||
dest_files=["res://.godot/imported/npc_generic_east_64.png-bf22e76d70922112e99bf747d85a0a04.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bx6s0yglmpt2l"
|
||||
path="res://.godot/imported/npc_generic_north_64.png-296e233a60c8b9efed025a82a69614df.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/npc_generic_north_64.png"
|
||||
dest_files=["res://.godot/imported/npc_generic_north_64.png-296e233a60c8b9efed025a82a69614df.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dpsuq148mksls"
|
||||
path="res://.godot/imported/npc_generic_south_64.png-c121e02d806f6dcc3ed440484827c258.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/npc_generic_south_64.png"
|
||||
dest_files=["res://.godot/imported/npc_generic_south_64.png-c121e02d806f6dcc3ed440484827c258.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dgt84hobbdumx"
|
||||
path="res://.godot/imported/npc_generic_west_64.png-9ee237384537f7357d37802b6cfae559.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/npc_generic_west_64.png"
|
||||
dest_files=["res://.godot/imported/npc_generic_west_64.png-9ee237384537f7357d37802b6cfae559.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bcmer1bsugk8b"
|
||||
path="res://.godot/imported/wall_structural_east_64.png-f868b267dcc1824e2b0fe213e49b4996.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/wall_structural_east_64.png"
|
||||
dest_files=["res://.godot/imported/wall_structural_east_64.png-f868b267dcc1824e2b0fe213e49b4996.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dd5j0q6cu674m"
|
||||
path="res://.godot/imported/wall_structural_north_64.png-f7eceb6d561e7e07b3bac2e28c659628.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/wall_structural_north_64.png"
|
||||
dest_files=["res://.godot/imported/wall_structural_north_64.png-f7eceb6d561e7e07b3bac2e28c659628.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cctlhvaolom3x"
|
||||
path="res://.godot/imported/wall_structural_south_64.png-c34925f9a5a3f6b5968e73e51953367e.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/wall_structural_south_64.png"
|
||||
dest_files=["res://.godot/imported/wall_structural_south_64.png-c34925f9a5a3f6b5968e73e51953367e.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ughjt5s8la2p"
|
||||
path="res://.godot/imported/wall_structural_west_64.png-7c39e0c692d32348190875c4ad99b227.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/wall_structural_west_64.png"
|
||||
dest_files=["res://.godot/imported/wall_structural_west_64.png-7c39e0c692d32348190875c4ad99b227.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -104,8 +104,12 @@ notifications:
|
||||
|
||||
# System
|
||||
save_complete: "Progress saved."
|
||||
load_complete: "Session restored."
|
||||
save_failed: "Save failed."
|
||||
load_failed: "Load failed."
|
||||
connection_lost: "Signal interrupted."
|
||||
connection_restored: "Signal restored."
|
||||
loading: "Resuming..."
|
||||
|
||||
# ============================================================
|
||||
# KNOWLEDGE PANEL LABELS
|
||||
@@ -125,6 +129,17 @@ knowledge_panel:
|
||||
confidence_medium: "Likely"
|
||||
confidence_low: "Unconfirmed"
|
||||
confidence_rumor: "Hearsay"
|
||||
# D-041 KnowledgeConfidence levels — displayed in journal panel
|
||||
confidence_direct: "Confirmed"
|
||||
confidence_knowsdetails: "Detailed"
|
||||
confidence_knowsof: "Known"
|
||||
confidence_suspects: "Unconfirmed"
|
||||
# D-041 KnowledgeSource labels — displayed in journal panel
|
||||
source_directobservation: "Observed"
|
||||
source_toldby: "Told"
|
||||
source_heard: "Overheard"
|
||||
source_inferred: "Inferred"
|
||||
source_background: "Prior"
|
||||
|
||||
# ============================================================
|
||||
# TUTORIAL TEXT (DIEGETIC)
|
||||
@@ -159,6 +174,8 @@ dialogue:
|
||||
menu:
|
||||
pause_title: "Paused"
|
||||
resume: "Resume"
|
||||
new_game: "New Game"
|
||||
continue: "Continue"
|
||||
settings: "Settings"
|
||||
save_game: "Save"
|
||||
load_game: "Load"
|
||||
@@ -167,6 +184,9 @@ menu:
|
||||
confirm_quit: "Unsaved progress will be lost."
|
||||
confirm_yes: "Yes"
|
||||
confirm_no: "No"
|
||||
load_game_browse: "LOAD GAME"
|
||||
load_game_back: "BACK"
|
||||
load_game_empty: "No saves found."
|
||||
|
||||
settings:
|
||||
audio_volume: "Volume"
|
||||
@@ -174,6 +194,12 @@ settings:
|
||||
text_speed: "Text Speed"
|
||||
fullscreen: "Fullscreen"
|
||||
language: "Language"
|
||||
# #646: AI-Enhanced Dialogue toggle (D-138)
|
||||
ai_section_header: "AI DIALOGUE"
|
||||
ai_dialogue_toggle: "AI-Enhanced Dialogue"
|
||||
ai_status_checking: "Speed not yet measured — will check on first enable."
|
||||
ai_status_ram_marginal: "Low memory — performance may vary."
|
||||
ai_battery_warning: "High battery usage"
|
||||
|
||||
# ============================================================
|
||||
# CHARACTER SELECTION (if applicable in v0.1)
|
||||
@@ -185,3 +211,10 @@ character_select:
|
||||
detective_name: "Commission Investigator"
|
||||
detective_tagline: "The manifests don't add up. Someone in this district knows why."
|
||||
confirm: "Begin"
|
||||
# #588: Card display strings — name, role, tone per archetype
|
||||
smuggler_card_name: "Smuggler"
|
||||
smuggler_card_role: "Freight logistics worker — Sova Transit"
|
||||
smuggler_card_tone: "Insider access. Social camouflage. The ring is your daily life."
|
||||
detective_card_name: "Detective"
|
||||
detective_card_role: "Commission investigator — External assignment"
|
||||
detective_card_tone: "Institutional authority. Analytical lattice. You were sent here."
|
||||
|
||||
@@ -11,18 +11,24 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="The Settled Reach"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
run/main_scene="res://scenes/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6", "GL Compatibility")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
[autoload]
|
||||
|
||||
; PlatformInfo MUST remain first — HardwareDetector (and other autoloads) depend on it
|
||||
; being initialised before their own _ready() runs. Do not reorder.
|
||||
PlatformInfo="*res://scripts/autoloads/platform_info.gd"
|
||||
Protocol="*res://scripts/protocol/protocol.gd"
|
||||
SimBridge="*res://scripts/autoloads/sim_bridge.gd"
|
||||
GameState="*res://scripts/autoloads/game_state.gd"
|
||||
InputMapper="*res://scripts/autoloads/input_mapper.gd"
|
||||
UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
SessionManager="*res://scripts/autoloads/session_manager.gd"
|
||||
HardwareDetector="*res://ui/hardware_detector.gd"
|
||||
|
||||
[audio]
|
||||
|
||||
@@ -125,11 +131,26 @@ debug_overlay={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
open_journal={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
teleport_hub={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
quicksave={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194336,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
quickload={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194337,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://char_select_scene_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/character_select.gd" id="1_charselect"]
|
||||
|
||||
; #588: Character archetype select — two-card overlay between New Game and main.tscn.
|
||||
; Keyboard: left/right to pick, Enter to confirm, ESC to cancel (no save dir created).
|
||||
|
||||
[node name="CharacterSelect" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_charselect")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.04, 0.04, 0.07, 0.97)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="."]
|
||||
layout_mode = 1
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_left = -200.0
|
||||
offset_top = 100.0
|
||||
offset_right = 200.0
|
||||
offset_bottom = 126.0
|
||||
grow_horizontal = 2
|
||||
text = "Choose your perspective."
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 16
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
|
||||
|
||||
[node name="Cards" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -316.0
|
||||
offset_top = -110.0
|
||||
offset_right = 316.0
|
||||
offset_bottom = 140.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 24
|
||||
alignment = 1
|
||||
|
||||
; --- Smuggler card ---
|
||||
|
||||
[node name="CardSmugglerWrapper" type="Control" parent="Cards"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(280, 240)
|
||||
mouse_filter = 0
|
||||
|
||||
[node name="CardBorder" type="ColorRect" parent="Cards/CardSmugglerWrapper"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.18, 0.22, 0.28, 1.0)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="CardInner" type="ColorRect" parent="Cards/CardSmugglerWrapper"]
|
||||
layout_mode = 1
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 2.0
|
||||
offset_top = 2.0
|
||||
offset_right = -2.0
|
||||
offset_bottom = -2.0
|
||||
color = Color(0.07, 0.07, 0.10, 1.0)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="Cards/CardSmugglerWrapper/CardInner"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 20.0
|
||||
offset_top = 20.0
|
||||
offset_right = -20.0
|
||||
offset_bottom = -20.0
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="NameLabel" type="Label" parent="Cards/CardSmugglerWrapper/CardInner/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Smuggler"
|
||||
theme_override_font_sizes/font_size = 26
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="RoleLabel" type="Label" parent="Cards/CardSmugglerWrapper/CardInner/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Freight logistics worker — Sova Transit"
|
||||
autowrap_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||
|
||||
[node name="Divider" type="Control" parent="Cards/CardSmugglerWrapper/CardInner/VBox"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 12)
|
||||
|
||||
[node name="ToneLabel" type="Label" parent="Cards/CardSmugglerWrapper/CardInner/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Insider access. Social camouflage. The ring is your daily life."
|
||||
autowrap_mode = 2
|
||||
theme_override_font_sizes/font_size = 12
|
||||
theme_override_colors/font_color = Color(0.416, 0.447, 0.510, 1.0)
|
||||
|
||||
; --- Detective card ---
|
||||
|
||||
[node name="CardDetectiveWrapper" type="Control" parent="Cards"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(280, 240)
|
||||
mouse_filter = 0
|
||||
|
||||
[node name="CardBorder" type="ColorRect" parent="Cards/CardDetectiveWrapper"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.18, 0.22, 0.28, 1.0)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="CardInner" type="ColorRect" parent="Cards/CardDetectiveWrapper"]
|
||||
layout_mode = 1
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 2.0
|
||||
offset_top = 2.0
|
||||
offset_right = -2.0
|
||||
offset_bottom = -2.0
|
||||
color = Color(0.07, 0.07, 0.10, 1.0)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="Cards/CardDetectiveWrapper/CardInner"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 20.0
|
||||
offset_top = 20.0
|
||||
offset_right = -20.0
|
||||
offset_bottom = -20.0
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="NameLabel" type="Label" parent="Cards/CardDetectiveWrapper/CardInner/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Detective"
|
||||
theme_override_font_sizes/font_size = 26
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="RoleLabel" type="Label" parent="Cards/CardDetectiveWrapper/CardInner/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Commission investigator — External assignment"
|
||||
autowrap_mode = 2
|
||||
theme_override_font_sizes/font_size = 13
|
||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||
|
||||
[node name="Divider" type="Control" parent="Cards/CardDetectiveWrapper/CardInner/VBox"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 12)
|
||||
|
||||
[node name="ToneLabel" type="Label" parent="Cards/CardDetectiveWrapper/CardInner/VBox"]
|
||||
layout_mode = 2
|
||||
text = "Institutional authority. Analytical lattice. You were sent here."
|
||||
autowrap_mode = 2
|
||||
theme_override_font_sizes/font_size = 12
|
||||
theme_override_colors/font_color = Color(0.416, 0.447, 0.510, 1.0)
|
||||
|
||||
[node name="ConfirmBtn" type="Button" parent="."]
|
||||
layout_mode = 1
|
||||
anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -60.0
|
||||
offset_top = -80.0
|
||||
offset_right = 60.0
|
||||
offset_bottom = -50.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
text = "Begin"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=29 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -21,7 +21,13 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
||||
[ext_resource type="Script" path="res://ui/debug_overlay.gd" id="22_debug"]
|
||||
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
|
||||
[ext_resource type="PackedScene" path="res://ui/examine_display.tscn" id="24_examine"]
|
||||
[ext_resource type="PackedScene" path="res://ui/journal_panel.tscn" id="25_journal"]
|
||||
[ext_resource type="PackedScene" path="res://ui/loading_screen.tscn" id="26_loading"]
|
||||
[ext_resource type="PackedScene" uid="uid://b2ndm9rvx8cqp" path="res://ui/debug_console.tscn" id="27_debug_console"]
|
||||
[ext_resource type="PackedScene" uid="uid://news_ticker_scene_sr" path="res://ui/news_ticker.tscn" id="28_newsticker"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -118,6 +124,9 @@ zoom = Vector2(2, 2)
|
||||
[node name="InsertOverlay" type="CanvasLayer" parent="."]
|
||||
layer = 10
|
||||
|
||||
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
|
||||
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
|
||||
|
||||
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
|
||||
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
|
||||
|
||||
@@ -130,6 +139,15 @@ layer = 10
|
||||
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
|
||||
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
|
||||
|
||||
; #151: Minimap — diegetic insert overlay, top-right, 160px circle (D-013, D-049 z-layer 6)
|
||||
[node name="Minimap" parent="InsertOverlay" instance=ExtResource("7_minimap")]
|
||||
|
||||
; #174: Examine result — non-interactive observe text overlay, auto-dismisses 5s (D-061 adjacent)
|
||||
[node name="ExamineDisplay" parent="InsertOverlay" instance=ExtResource("24_examine")]
|
||||
|
||||
; #264: Journal panel — knowledge graph review, toggle J key, read-only (D-041)
|
||||
[node name="JournalPanel" parent="InsertOverlay" instance=ExtResource("25_journal")]
|
||||
|
||||
; --- UI layer (CanvasLayer 20) ---
|
||||
; HUD, monologue, cursor — always visible, not affected by fog or camera.
|
||||
[node name="UILayer" type="CanvasLayer" parent="."]
|
||||
@@ -137,8 +155,6 @@ layer = 20
|
||||
|
||||
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
|
||||
|
||||
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
|
||||
|
||||
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
||||
|
||||
; D-053: Stance indicator — top-right, color-coded
|
||||
@@ -163,6 +179,9 @@ offset_bottom = 400
|
||||
mouse_filter = 2
|
||||
script = ExtResource("22_debug")
|
||||
|
||||
; #592: News ticker — scrolling headline bar, visible in bar zone only (D-049 z-layer 7)
|
||||
[node name="NewsTicker" parent="UILayer" instance=ExtResource("28_newsticker")]
|
||||
|
||||
; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer
|
||||
[node name="CursorRenderer" type="Node2D" parent="UILayer"]
|
||||
script = ExtResource("10_cursor")
|
||||
@@ -177,3 +196,9 @@ layer = 30
|
||||
|
||||
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
|
||||
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
|
||||
|
||||
; #257: Loading screen — full-screen overlay during save/load round-trip
|
||||
[node name="LoadingScreen" parent="ModalLayer" instance=ExtResource("26_loading")]
|
||||
|
||||
; #581: Debug console — tilde key toggles, bottom 40% of screen
|
||||
[node name="DebugConsole" parent="ModalLayer" instance=ExtResource("27_debug_console")]
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://main_menu_sr"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_mainmenu"]
|
||||
|
||||
; Main menu — New Game / Continue / Quit.
|
||||
; #258: D-085 per-game save directory created on New Game.
|
||||
|
||||
[node name="MainMenu" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_mainmenu")
|
||||
|
||||
[node name="Background" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.05, 0.05, 0.08, 1.0)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -120.0
|
||||
offset_top = -80.0
|
||||
offset_right = 120.0
|
||||
offset_bottom = 100.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 16
|
||||
alignment = 1
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "THE SETTLED REACH"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 36
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
|
||||
|
||||
[node name="Spacer" type="Control" parent="VBox"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(0, 24)
|
||||
|
||||
[node name="NewGameBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "NEW GAME"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="ContinueBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "CONTINUE"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="LoadGameBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "LOAD GAME"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.906, 0.773, 0.278, 1.0)
|
||||
|
||||
[node name="QuitBtn" type="Button" parent="VBox"]
|
||||
layout_mode = 2
|
||||
text = "QUIT"
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||
|
||||
[node name="LoadGamePanel" type="Control" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
visible = false
|
||||
|
||||
[node name="PanelBg" type="ColorRect" parent="LoadGamePanel"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
color = Color(0.05, 0.05, 0.08, 0.96)
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="VBox" type="VBoxContainer" parent="LoadGamePanel"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -160.0
|
||||
offset_top = -180.0
|
||||
offset_right = 160.0
|
||||
offset_bottom = 180.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="LoadGamePanel/VBox"]
|
||||
layout_mode = 2
|
||||
text = "LOAD GAME"
|
||||
horizontal_alignment = 1
|
||||
theme_override_font_sizes/font_size = 20
|
||||
theme_override_colors/font_color = Color(0.784, 0.816, 0.878, 1.0)
|
||||
|
||||
[node name="SavesScroll" type="ScrollContainer" parent="LoadGamePanel/VBox"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(320, 240)
|
||||
|
||||
[node name="SavesList" type="VBoxContainer" parent="LoadGamePanel/VBox/SavesScroll"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="BackBtn" type="Button" parent="LoadGamePanel/VBox"]
|
||||
layout_mode = 2
|
||||
text = "BACK"
|
||||
theme_override_font_sizes/font_size = 14
|
||||
theme_override_colors/font_color = Color(0.533, 0.565, 0.627, 1.0)
|
||||
@@ -10,6 +10,11 @@ extends Node
|
||||
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
|
||||
const CHIME_RECOGNITION := "sfx_monologue_chime"
|
||||
|
||||
# --- D-067: Triangle activation chime (#590, D-072/D-089) ---
|
||||
# Fires once per session when the triangle's tell_state shifts to RoutineDeviation.
|
||||
# Sharper variant (D-067: "contradiction/anomaly") — sfx_monologue_chime_urgent.ogg.
|
||||
const CHIME_ACTIVATION := "sfx_monologue_chime_urgent"
|
||||
|
||||
# --- Bus names (D-068) ---
|
||||
const BUS_MUSIC := "Music"
|
||||
const BUS_AMBIENT := "Ambient"
|
||||
|
||||
@@ -8,13 +8,29 @@ extends Node
|
||||
# Used by fog shader to distinguish visual treatment per tile.
|
||||
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
||||
const VIS_PERIPHERAL: int = 180 # In LOS, peripheral sector — light fog dimming
|
||||
const VIS_PERIPHERAL: int = 180 # DEPRECATED: peripheral sector removed in Sprint 22 (#569). Retained — tests still reference it.
|
||||
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||
|
||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
|
||||
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
|
||||
|
||||
# Zone temperature tints (D-059 + D-046, Sprint 22) — keyed by zone_id string from server.
|
||||
# Matches audio_manager.gd ZONE_ASSETS zone_id strings for consistent zone semantics.
|
||||
# Low saturation is intentional (D-046): tints are subtle — distinguishable as warm/cool/neutral
|
||||
# in side-by-side comparison, not garish. The "Hopper test" validates this.
|
||||
# Colors are dark tints used as the fog overlay in the deep fog zone:
|
||||
# hub/workplace: #1a1f2e (cool blue-dark — terminal, institutional)
|
||||
# bar: #2a1f15 (warm amber-dark — social, inhabited)
|
||||
# corridor: #1a1a1a (neutral dark — transitional, maintenance)
|
||||
const ZONE_TINTS: Dictionary = {
|
||||
"hub": Color(0.102, 0.122, 0.180), # #1a1f2e — cool blue-dark
|
||||
"workplace": Color(0.102, 0.122, 0.180), # same as hub
|
||||
"bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark
|
||||
"corridor": Color(0.102, 0.102, 0.102), # #1a1a1a — neutral dark
|
||||
}
|
||||
const ZONE_TINT_DEFAULT: Color = Color(0.102, 0.102, 0.102) # #1a1a1a neutral
|
||||
|
||||
var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
|
||||
var visibility_texture: ImageTexture
|
||||
var exploration_texture: ImageTexture
|
||||
@@ -25,16 +41,34 @@ var _exp_bytes: PackedByteArray
|
||||
var _vis_image: Image
|
||||
var _exp_image: Image
|
||||
var _tint_image: Image
|
||||
# Zone tint stored as 3-channel RGB bytes (R, G, B per pixel) for preservation across resizes
|
||||
var _tint_bytes: PackedByteArray
|
||||
var _width: int = 1
|
||||
var _height: int = 1
|
||||
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
|
||||
|
||||
## Debug flag — when true, fog.gdshader renders raw exploration texture
|
||||
## as colored overlay (green=visible, blue=explored, red=unexplored).
|
||||
## Toggle via FogState.debug_exploration = true in the console.
|
||||
var debug_exploration: bool = false
|
||||
|
||||
## Deterministic shader time for visual test captures.
|
||||
## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec().
|
||||
## Set before settle frames so noise phase is reproducible across runs.
|
||||
var override_time: float = -1.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_resize(Rect2i(0, 0, 64, 64))
|
||||
|
||||
|
||||
func _resize(bounds: Rect2i) -> void:
|
||||
var old_bounds := map_bounds
|
||||
var old_exp := _exp_bytes
|
||||
var old_tint := _tint_bytes # empty on first call (_ready); guard at line 104 skips copy
|
||||
var old_w := _width
|
||||
var old_h := _height
|
||||
|
||||
map_bounds = bounds
|
||||
_width = maxi(bounds.size.x, 1)
|
||||
_height = maxi(bounds.size.y, 1)
|
||||
@@ -49,41 +83,92 @@ func _resize(bounds: Rect2i) -> void:
|
||||
_exp_bytes = PackedByteArray()
|
||||
_exp_bytes.resize(sz)
|
||||
_exp_bytes.fill(EXP_UNEXPLORED)
|
||||
# Preserve exploration data from old bounds into new bounds
|
||||
if old_exp.size() > 0 and old_w > 0 and old_h > 0:
|
||||
var dx: int = old_bounds.position.x - bounds.position.x
|
||||
var dy: int = old_bounds.position.y - bounds.position.y
|
||||
for oy in range(old_h):
|
||||
var ny: int = oy + dy
|
||||
if ny < 0 or ny >= _height:
|
||||
continue
|
||||
for ox in range(old_w):
|
||||
var nx: int = ox + dx
|
||||
if nx < 0 or nx >= _width:
|
||||
continue
|
||||
var old_val: int = old_exp[oy * old_w + ox]
|
||||
if old_val > EXP_UNEXPLORED:
|
||||
_exp_bytes[ny * _width + nx] = old_val
|
||||
_exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
|
||||
exploration_texture = ImageTexture.create_from_image(_exp_image)
|
||||
|
||||
# Zone tint — neutral dark for Sprint 6 (zone metadata deferred)
|
||||
_tint_image = Image.create(_width, _height, false, Image.FORMAT_RGB8)
|
||||
_tint_image.fill(Color(0.05, 0.05, 0.08))
|
||||
# Zone tint — 3 bytes per pixel (RGB), default neutral dark
|
||||
var tint_sz := sz * 3
|
||||
_tint_bytes = PackedByteArray()
|
||||
_tint_bytes.resize(tint_sz)
|
||||
var default_r := int(ZONE_TINT_DEFAULT.r * 255.0)
|
||||
var default_g := int(ZONE_TINT_DEFAULT.g * 255.0)
|
||||
var default_b := int(ZONE_TINT_DEFAULT.b * 255.0)
|
||||
for i in range(sz):
|
||||
_tint_bytes[i * 3 + 0] = default_r
|
||||
_tint_bytes[i * 3 + 1] = default_g
|
||||
_tint_bytes[i * 3 + 2] = default_b
|
||||
# Preserve zone tint data from old bounds (zone tints are stable — tile zone never changes)
|
||||
if old_tint.size() > 0 and old_w > 0 and old_h > 0:
|
||||
var dx: int = old_bounds.position.x - bounds.position.x
|
||||
var dy: int = old_bounds.position.y - bounds.position.y
|
||||
for oy in range(old_h):
|
||||
var ny: int = oy + dy
|
||||
if ny < 0 or ny >= _height:
|
||||
continue
|
||||
for ox in range(old_w):
|
||||
var nx: int = ox + dx
|
||||
if nx < 0 or nx >= _width:
|
||||
continue
|
||||
var old_idx := (oy * old_w + ox) * 3
|
||||
var new_idx := (ny * _width + nx) * 3
|
||||
_tint_bytes[new_idx + 0] = old_tint[old_idx + 0]
|
||||
_tint_bytes[new_idx + 1] = old_tint[old_idx + 1]
|
||||
_tint_bytes[new_idx + 2] = old_tint[old_idx + 2]
|
||||
_tint_image = Image.create_from_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
|
||||
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
|
||||
|
||||
_prev_visible.clear()
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
# Resize if map bounds changed
|
||||
var tiles := GameState.visible_tiles
|
||||
if tiles.size() > 0:
|
||||
var new_bounds := _compute_bounds(tiles)
|
||||
# Grow bounds to include newly visible tiles — never shrink, so explored
|
||||
# tiles behind the player stay in the texture and render as deep fog
|
||||
# instead of black. Exploration data is preserved across resizes.
|
||||
# Use visible_positions (always populated from server snapshots) instead of
|
||||
# visible_tiles, which stays empty in live server mode because the server
|
||||
# sends tile_kind but game_state.gd's population check expects "type".
|
||||
var positions: Dictionary = GameState.visible_positions
|
||||
if positions.size() > 0:
|
||||
var new_bounds := _grow_bounds_from_positions(positions)
|
||||
if new_bounds != map_bounds:
|
||||
_resize(new_bounds)
|
||||
|
||||
var ox: int = map_bounds.position.x
|
||||
var oy: int = map_bounds.position.y
|
||||
var positions: Dictionary = GameState.visible_positions
|
||||
var sectors: Dictionary = GameState.visibility_sectors
|
||||
|
||||
# TODO(v0.2): gradual decay over game-time instead of immediate EXP_VISIBLE→EXP_EXPLORED
|
||||
|
||||
# 1. Clear visibility, then write current LOS
|
||||
# 1. Clear visibility, then write current LOS (all tiles are Forward)
|
||||
_vis_bytes.fill(VIS_HIDDEN)
|
||||
for pos in positions:
|
||||
var px: int = pos.x - ox
|
||||
var py: int = pos.y - oy
|
||||
if px < 0 or py < 0 or px >= _width or py >= _height:
|
||||
continue
|
||||
var sector: String = sectors.get(pos, "Forward")
|
||||
_vis_bytes[py * _width + px] = VIS_FORWARD if sector == "Forward" else VIS_PERIPHERAL
|
||||
_vis_bytes[py * _width + px] = VIS_FORWARD
|
||||
# #585: BoundaryWall margin tiles — fog lifts so wall content composites correctly,
|
||||
# but NOT in visible_positions so they don't persist as explored memory.
|
||||
for pos in GameState.boundary_positions:
|
||||
var px: int = pos.x - ox
|
||||
var py: int = pos.y - oy
|
||||
if px < 0 or py < 0 or px >= _width or py >= _height:
|
||||
continue
|
||||
_vis_bytes[py * _width + px] = VIS_FORWARD
|
||||
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
|
||||
visibility_texture.update(_vis_image)
|
||||
|
||||
@@ -106,24 +191,58 @@ func update_from_state() -> void:
|
||||
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
|
||||
exploration_texture.update(_exp_image)
|
||||
|
||||
# 3. Zone tint: write zone temperature color for currently visible tiles.
|
||||
# Zone data is stable (tile zone never changes) so we only write on first sight.
|
||||
# Data persists in _tint_bytes across frames and across resizes.
|
||||
# visible_tiles carries zone_id per tile (populated from snapshot "tiles" or
|
||||
# "visible_tiles" with type field — see game_state.gd apply_snapshot).
|
||||
var tiles := GameState.visible_tiles
|
||||
var tint_dirty := false
|
||||
for tile in tiles:
|
||||
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
|
||||
continue
|
||||
var zone_id: String = str(tile.get("zone_id", ""))
|
||||
if zone_id.is_empty():
|
||||
continue
|
||||
var tint_color: Color = ZONE_TINTS.get(zone_id, ZONE_TINT_DEFAULT)
|
||||
var px: int = int(tile.x) - ox
|
||||
var py: int = int(tile.y) - oy
|
||||
if px < 0 or py < 0 or px >= _width or py >= _height:
|
||||
continue
|
||||
var tint_idx := (py * _width + px) * 3
|
||||
var new_r := int(tint_color.r * 255.0)
|
||||
var new_g := int(tint_color.g * 255.0)
|
||||
var new_b := int(tint_color.b * 255.0)
|
||||
# Only update if different from current (avoid spurious texture uploads)
|
||||
if _tint_bytes[tint_idx] != new_r or _tint_bytes[tint_idx + 1] != new_g or _tint_bytes[tint_idx + 2] != new_b:
|
||||
_tint_bytes[tint_idx + 0] = new_r
|
||||
_tint_bytes[tint_idx + 1] = new_g
|
||||
_tint_bytes[tint_idx + 2] = new_b
|
||||
tint_dirty = true
|
||||
if tint_dirty:
|
||||
_tint_image.set_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
|
||||
zone_tint_texture.update(_tint_image)
|
||||
|
||||
# Shallow copy — correct for Dictionary<Vector2i, bool/String> values
|
||||
_prev_visible = positions.duplicate()
|
||||
|
||||
|
||||
func _compute_bounds(tiles: Array) -> Rect2i:
|
||||
func _grow_bounds_from_positions(positions: Dictionary) -> Rect2i:
|
||||
## Compute bounds from visible_positions (Dictionary[Vector2i, bool]).
|
||||
var min_x := 999999
|
||||
var min_y := 999999
|
||||
var max_x := -999999
|
||||
var max_y := -999999
|
||||
for tile in tiles:
|
||||
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
|
||||
continue
|
||||
min_x = mini(min_x, int(tile.x))
|
||||
min_y = mini(min_y, int(tile.y))
|
||||
max_x = maxi(max_x, int(tile.x))
|
||||
max_y = maxi(max_y, int(tile.y))
|
||||
# Guard: all tiles invalid (no x/y) — sentinels would produce negative Rect2i
|
||||
for pos in positions:
|
||||
min_x = mini(min_x, pos.x)
|
||||
min_y = mini(min_y, pos.y)
|
||||
max_x = maxi(max_x, pos.x)
|
||||
max_y = maxi(max_y, pos.y)
|
||||
if min_x > max_x:
|
||||
return Rect2i(0, 0, 1, 1)
|
||||
# Margin for fog gradient bleed at edges
|
||||
return Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)
|
||||
return map_bounds
|
||||
var tile_bounds := Rect2i(min_x - 8, min_y - 8, max_x - min_x + 17, max_y - min_y + 17)
|
||||
if map_bounds.size.x <= 1 and map_bounds.size.y <= 1:
|
||||
return tile_bounds
|
||||
return map_bounds.merge(tile_bounds)
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
extends Node
|
||||
|
||||
signal game_id_changed(new_id: String)
|
||||
|
||||
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
|
||||
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
||||
# Tiles use format: [{x, y, z, type}].
|
||||
var current_snapshot: Dictionary = {}
|
||||
|
||||
# D-085 (#258): Active game session identifier. Format: <YYYYMMDD>-<HHMMSS>-<hex6>
|
||||
# Set by SessionManager.new_game() or SessionManager.resume_game().
|
||||
# Empty string when no session is active (main menu state).
|
||||
var current_game_id: String = "":
|
||||
set(v):
|
||||
current_game_id = v
|
||||
game_id_changed.emit(v)
|
||||
var current_tick: int = 0
|
||||
var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
var visible_tiles: Array = []
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups (normal LOS tiles)
|
||||
var boundary_positions: Dictionary = {} # Vector2i -> true, BoundaryWall margin tiles (#585) — visible in fog but not explored
|
||||
|
||||
# v2 fields (D-015, D-031)
|
||||
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
|
||||
@@ -56,10 +67,47 @@ var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode fla
|
||||
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
|
||||
var insert_active: bool = true
|
||||
|
||||
# #175: World seed for deterministic simulation (D-010, D-029).
|
||||
# Set by SessionManager.new_game(), sent to server via StartupMessage in SimBridge.
|
||||
# Same seed → same EntanglementConfig → same NPC population across playthroughs.
|
||||
# Persists for the session lifetime; not overwritten by apply_snapshot().
|
||||
var world_seed: int = 0
|
||||
|
||||
# #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field.
|
||||
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||
var rng_seed: Variant = null
|
||||
|
||||
# v15 fields (#554, D-085): save/load result from server.
|
||||
# {success: bool, kind: "save"|"load", error: Variant} or null.
|
||||
# One-shot: consumed by main.gd after display, then set back to null.
|
||||
var save_result: Variant = null
|
||||
|
||||
# v18 fields (#580): debug console response from server.
|
||||
# {command: String, text: String, success: bool} or null.
|
||||
# One-shot: consumed by main.gd and forwarded to DebugConsole, then set to null.
|
||||
var debug_response: Variant = null
|
||||
|
||||
# #257: Pending load path — set by main menu "Load Game" selection.
|
||||
# main.gd sends LOAD_GAME on startup if non-empty, then clears this field.
|
||||
# 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"
|
||||
|
||||
# #646: AI-Enhanced Dialogue enabled state (D-138).
|
||||
# Runtime toggle — true means the LLM re-voicing pipeline should run (server-side).
|
||||
# Default: true (opt-out model per D-138 §8). Hardware detector may disable at startup
|
||||
# if RAM is insufficient. Persisted to server SQLite via ChangeSettings IPC.
|
||||
var ai_enhanced_dialogue_enabled: bool = true
|
||||
|
||||
# v20 fields (#627, D-138): settings response from server.
|
||||
# One-shot: {kind: "full", settings: [{key, value}]} or {kind: "ack", success, key, error} or null.
|
||||
# "full" response hydrates ai_enhanced_dialogue_enabled (server is authoritative for persisted state).
|
||||
var settings_response: Variant = null
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
@@ -70,6 +118,21 @@ var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
|
||||
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
|
||||
var conversation_ended: Array = [] # [{speaker_id, target_id}]
|
||||
|
||||
# v10 fields (#151, D-013): Discovered POIs from server (#148/#149).
|
||||
# Format: [{poi_id, name, x, y, z, category}]. Persists between snapshots unless
|
||||
# server explicitly sends an empty array (cleared locations are not typical in v0.1).
|
||||
# Populated from snapshot "poi_list" field — only updated when field present.
|
||||
var discovered_pois: Array = []
|
||||
|
||||
# v14 fields (#174, #242): Character-filtered examine result.
|
||||
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||
var current_examine_result: Variant = null
|
||||
|
||||
# v14 fields (#264, D-041): Player knowledge graph dump for journal panel.
|
||||
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||
var player_knowledge: Variant = null
|
||||
|
||||
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
||||
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
||||
var medium_sound_events: Array = []
|
||||
@@ -79,14 +142,17 @@ var medium_sound_events: Array = []
|
||||
var close_sound_events: Array = []
|
||||
|
||||
# D-071 (#530): Consecutive ticks without player position change.
|
||||
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
|
||||
# D-020: Server-authoritative — read from snapshot "stationary_ticks" field.
|
||||
# Fallback: client-side accumulation (deprecated, remove when server populates field).
|
||||
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
||||
var stationary_ticks: int = 0
|
||||
# DEPRECATED: Only used by client-side accumulation fallback. Remove with fallback.
|
||||
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
||||
|
||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
|
||||
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
|
||||
# D-020: Read directly from snapshot "zone_id" field.
|
||||
# Fallback: client-side tile lookup (deprecated, remove when server populates field).
|
||||
# Empty string when zone_id field absent.
|
||||
var current_zone_id: String = ""
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
@@ -110,12 +176,19 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
visible_entities.size()])
|
||||
|
||||
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
|
||||
# Compares current player_position against previous snapshot's position.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
# D-020/D-071 (#530): Server-authoritative stationary_ticks for ListeningFocus boost.
|
||||
# Prefer server-sent value; fall back to client-side accumulation until server populates.
|
||||
if snapshot.has("stationary_ticks") and snapshot.stationary_ticks is int:
|
||||
# D-020: direct field assignment from server-authoritative snapshot.
|
||||
stationary_ticks = snapshot.stationary_ticks
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
# DEPRECATED fallback — client-side accumulation. Remove when server sends
|
||||
# "stationary_ticks" in ObserverSnapshot (D-020 violation: derives behavior-
|
||||
# driving state on the client). Server tracks this in ListeningFocus component.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
_prev_player_position = player_position
|
||||
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
||||
@@ -240,29 +313,106 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
||||
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
||||
# positional indexing — use the same tile iteration below instead.
|
||||
current_zone_id = ""
|
||||
var _px := int(player_position.x)
|
||||
var _py := int(player_position.y)
|
||||
for _ztile in visible_tiles:
|
||||
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
||||
current_zone_id = _ztile.get("zone_id", "")
|
||||
break
|
||||
# v10: discovered_pois (#151, D-013) — server sends POIs discovered by the player.
|
||||
# Accepts "discovered_pois" or "poi_list" key — both map to the same client field.
|
||||
# Only update if the field is present — absence means "no change since last tick".
|
||||
if snapshot.has("discovered_pois") and snapshot.discovered_pois is Array:
|
||||
discovered_pois = snapshot.discovered_pois
|
||||
elif snapshot.has("poi_list") and snapshot.poi_list is Array:
|
||||
discovered_pois = snapshot.poi_list
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation from Examine verb.
|
||||
if snapshot.has("examine_result") and snapshot.examine_result is Dictionary:
|
||||
current_examine_result = snapshot.examine_result
|
||||
else:
|
||||
current_examine_result = null
|
||||
|
||||
# v15: save_result (#554, D-085) — one-shot save/load confirmation from server.
|
||||
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
|
||||
save_result = snapshot.save_result
|
||||
else:
|
||||
save_result = null
|
||||
|
||||
# v18: debug_response (#580) — debug console command result.
|
||||
if snapshot.has("debug_response") and snapshot.debug_response is Dictionary:
|
||||
debug_response = snapshot.debug_response
|
||||
else:
|
||||
debug_response = null
|
||||
|
||||
# v20: settings_response (#627, D-138) — one-shot settings ack/dump from server.
|
||||
# "full" kind → iterate settings array and hydrate matching fields.
|
||||
if snapshot.has("settings_response") and snapshot.settings_response is Dictionary:
|
||||
settings_response = snapshot.settings_response
|
||||
var sr: Dictionary = snapshot.settings_response
|
||||
if sr.get("kind") == "full":
|
||||
var sr_settings: Variant = sr.get("settings")
|
||||
if sr_settings is Array:
|
||||
for entry in sr_settings:
|
||||
if not entry is Dictionary:
|
||||
continue
|
||||
if entry.get("key") == "ai_dialogue.enabled":
|
||||
var val: Variant = entry.get("value")
|
||||
if val != null:
|
||||
ai_enhanced_dialogue_enabled = _extract_bool_setting("ai_dialogue.enabled", val)
|
||||
else:
|
||||
settings_response = null
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# Only update when field is present (null means no change, server sends when KG changes).
|
||||
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
|
||||
player_knowledge = snapshot.player_knowledge
|
||||
|
||||
# D-020/D-073 (#529): Server-authoritative zone_id for zone ambient crossfade.
|
||||
# Prefer server-sent top-level value; fall back to client-side tile lookup until
|
||||
# server populates top-level "zone_id" in ObserverSnapshot.
|
||||
if snapshot.has("zone_id") and snapshot.zone_id is String:
|
||||
# D-020: direct field assignment from server-authoritative snapshot.
|
||||
current_zone_id = snapshot.zone_id
|
||||
else:
|
||||
# DEPRECATED fallback — client-side tile lookup. Remove when server sends
|
||||
# top-level "zone_id" in ObserverSnapshot (D-020 violation: derives zone
|
||||
# identity on the client via tile iteration). Server sends zone_id per
|
||||
# VisibleTile but not as a top-level snapshot field.
|
||||
var _tile_by_coord: Dictionary = {}
|
||||
for vtile in visible_tiles:
|
||||
if vtile is Dictionary and vtile.has("x") and vtile.has("y"):
|
||||
_tile_by_coord[Vector2i(vtile.x, vtile.y)] = vtile
|
||||
var player_pos_key := Vector2i(int(player_position.x), int(player_position.y))
|
||||
var player_tile = _tile_by_coord.get(player_pos_key, null)
|
||||
current_zone_id = player_tile.get("zone_id", "") if player_tile else ""
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
# Derives visible_positions when not explicitly provided (real server mode).
|
||||
# #585: BoundaryWall tiles go to boundary_positions — rendered in fog but not marked explored.
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
visibility_sectors.clear()
|
||||
var has_explicit_positions := snapshot.has("visible_positions")
|
||||
if not has_explicit_positions:
|
||||
visible_positions.clear()
|
||||
boundary_positions.clear()
|
||||
for vtile in snapshot.visible_tiles:
|
||||
if not vtile is Dictionary or not vtile.has("x") or not vtile.has("y"):
|
||||
continue
|
||||
var pos := Vector2i(vtile.x, vtile.y)
|
||||
var vis_sector: String = vtile.get("visibility", "")
|
||||
if vtile.has("visibility"):
|
||||
visibility_sectors[pos] = vtile.visibility
|
||||
if not has_explicit_positions:
|
||||
visibility_sectors[pos] = vis_sector
|
||||
# #585: BoundaryWall tiles are margin tiles visible through fog but not persistently
|
||||
# explored — they don't update the player's exploration memory when they leave LOS.
|
||||
if vis_sector == "BoundaryWall":
|
||||
boundary_positions[pos] = true
|
||||
elif not has_explicit_positions:
|
||||
visible_positions[pos] = true
|
||||
|
||||
|
||||
# -- Helpers ------------------------------------------------------------------
|
||||
|
||||
## Extract a bool from a tagged-union {"Bool": true} or plain bool value.
|
||||
## Handles both serde encoding styles; emits push_warning on unrecognised format.
|
||||
static func _extract_bool_setting(key: String, val: Variant) -> bool:
|
||||
if val is bool:
|
||||
return val
|
||||
if val is Dictionary and val.has("Bool"):
|
||||
return bool(val["Bool"])
|
||||
push_warning("GameState: unexpected type for setting '%s': %s" % [key, str(val)])
|
||||
return false
|
||||
|
||||
@@ -19,8 +19,15 @@ enum Action {
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
|
||||
SET_FACING, # D-054: facing octant update (no movement)
|
||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
||||
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
|
||||
DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server
|
||||
CHANGE_SETTINGS, # #646: persist a setting to server SQLite — sends {key, value} to server
|
||||
REQUEST_ALL_SETTINGS, # #646: request full settings dump from server after handshake (unit variant)
|
||||
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
@@ -106,15 +113,29 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
action = Action.TOGGLE_STANCE_DOWN
|
||||
elif event.is_action_pressed("bug_report"):
|
||||
action = Action.BUG_REPORT
|
||||
elif event.is_action_pressed("open_journal"):
|
||||
action = Action.OPEN_JOURNAL
|
||||
elif event.is_action_pressed("teleport_hub"):
|
||||
if GameState.gauntlet_mode:
|
||||
action = Action.TELEPORT_HUB
|
||||
elif event.is_action_pressed("quicksave"):
|
||||
action = Action.SAVE_GAME
|
||||
elif event.is_action_pressed("quickload"):
|
||||
action = Action.LOAD_GAME
|
||||
|
||||
if action != -1:
|
||||
input_queue.append({
|
||||
var entry := {
|
||||
"action": action,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
}
|
||||
# #554: Attach save path for SaveGame/LoadGame actions
|
||||
if action == Action.SAVE_GAME or action == Action.LOAD_GAME:
|
||||
var game_id := GameState.current_game_id
|
||||
if game_id.is_empty():
|
||||
get_viewport().set_input_as_handled()
|
||||
return # No active session — ignore save/load
|
||||
entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"}
|
||||
input_queue.append(entry)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
extends Node
|
||||
## Platform abstraction layer — D-138 §8, D-141. Central location for all OS queries.
|
||||
## Owned by Stig (UI Developer). Registered FIRST in project.godot so other
|
||||
## autoloads can read its properties in their own _ready().
|
||||
##
|
||||
## Sections:
|
||||
## Power: PowerProfile enum, 30s poll timer, power_profile_changed signal.
|
||||
## raw_power_state / battery_percent updated each poll.
|
||||
## Memory: free_memory_mb / total_memory_mb, refresh_memory() on demand.
|
||||
## CPU: cpu_name / cpu_logical_cores — read once in _ready().
|
||||
## GPU: gpu_name / gpu_vendor / gpu_api_version / gpu_type / gpu_driver_info.
|
||||
## Guarded for headless/server builds where RenderingServer has no device.
|
||||
## Platform: platform_name / os_version / distribution / is_sandboxed.
|
||||
## Display: screen_count / screen_size / screen_dpi / screen_refresh_rate / display_scale.
|
||||
## Locale: locale / locale_language.
|
||||
## Paths: Read-once OS path constants, populated in _ready().
|
||||
## Diag: get_diagnostics() — flat dict of all properties for bug reports.
|
||||
|
||||
|
||||
# -- Power profile ------------------------------------------------------------
|
||||
|
||||
## High-level power classification. POWER_SAVER reserved for future OS API.
|
||||
enum PowerProfile {
|
||||
FULL = 0, # Plugged in (charged, charging, or no battery) — no restrictions
|
||||
BATTERY = 1, # On battery — AI inference should be suspended per D-138 §8 Layer 3
|
||||
POWER_SAVER = 2 # System-level power-saver mode (future: no cross-platform API yet)
|
||||
}
|
||||
|
||||
## Emitted when the detected power profile changes.
|
||||
signal power_profile_changed(old_profile: int, new_profile: int)
|
||||
|
||||
## Current power profile. Updated by the 30-second poll timer.
|
||||
var power_profile: PowerProfile = PowerProfile.FULL
|
||||
|
||||
## Raw OS power_state integer from the last poll. 0 = unknown, 1 = on battery, etc.
|
||||
var raw_power_state: int = 0
|
||||
|
||||
## Battery charge percentage (0–100). -1 if not available or not on battery.
|
||||
var battery_percent: int = -1
|
||||
|
||||
## How often (seconds) to re-poll OS for power state changes.
|
||||
const POWER_POLL_INTERVAL := 30.0
|
||||
|
||||
# OS power_state integer constants (Godot 4 — same values as POWERSTATE_* enum).
|
||||
const _POWER_STATE_UNKNOWN := 0
|
||||
const _POWER_STATE_ON_BATTERY := 1
|
||||
const _POWER_STATE_NO_BATTERY := 2
|
||||
const _POWER_STATE_CHARGING := 3
|
||||
const _POWER_STATE_CHARGED := 4
|
||||
|
||||
|
||||
# -- Memory -------------------------------------------------------------------
|
||||
|
||||
## Free physical RAM in megabytes. Call refresh_memory() before reading if staleness matters.
|
||||
var free_memory_mb: float = 0.0
|
||||
|
||||
## Total physical RAM in megabytes. Populated once in _ready(); doesn't change at runtime.
|
||||
var total_memory_mb: float = 0.0
|
||||
|
||||
|
||||
# -- CPU ----------------------------------------------------------------------
|
||||
|
||||
## Human-readable processor name (e.g. "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz").
|
||||
var cpu_name: String = ""
|
||||
|
||||
## Number of logical CPU cores reported by the OS.
|
||||
var cpu_logical_cores: int = 0
|
||||
|
||||
|
||||
# -- GPU ----------------------------------------------------------------------
|
||||
|
||||
## GPU display name (e.g. "NVIDIA GeForce RTX 3080").
|
||||
var gpu_name: String = ""
|
||||
|
||||
## GPU vendor string (e.g. "NVIDIA Corporation").
|
||||
var gpu_vendor: String = ""
|
||||
|
||||
## Graphics API version string (e.g. "OpenGL 4.6.0 ...").
|
||||
var gpu_api_version: String = ""
|
||||
|
||||
## GPU type classification: "discrete" | "integrated" | "virtual" | "cpu" | "other".
|
||||
var gpu_type: String = "other"
|
||||
|
||||
## Low-level driver info strings from the OS (may be empty on some platforms).
|
||||
var gpu_driver_info: PackedStringArray = PackedStringArray()
|
||||
|
||||
|
||||
# -- Platform identity --------------------------------------------------------
|
||||
|
||||
## OS name reported by Godot (e.g. "Linux", "Windows", "macOS").
|
||||
var platform_name: String = ""
|
||||
|
||||
## Full OS version string (e.g. "Ubuntu 22.04.3 LTS").
|
||||
var os_version: String = ""
|
||||
|
||||
## Linux distribution name (e.g. "Ubuntu"); empty on non-Linux platforms.
|
||||
var distribution: String = ""
|
||||
|
||||
## True if the application is running inside a sandbox (Flatpak, Snap, macOS sandbox, etc.).
|
||||
var is_sandboxed: bool = false
|
||||
|
||||
|
||||
# -- Display ------------------------------------------------------------------
|
||||
|
||||
## Number of connected screens.
|
||||
var screen_count: int = 1
|
||||
|
||||
## Size of the primary screen in pixels.
|
||||
var screen_size: Vector2i = Vector2i(1920, 1080)
|
||||
|
||||
## DPI of the primary screen.
|
||||
var screen_dpi: int = 96
|
||||
|
||||
## Refresh rate of the primary screen in Hz. Falls back to 60.0 if OS reports < 0.
|
||||
var screen_refresh_rate: float = 60.0
|
||||
|
||||
## UI scale factor. Uses OS-reported scale on macOS; falls back to dpi / 96.0 elsewhere.
|
||||
var display_scale: float = 1.0
|
||||
|
||||
|
||||
# -- Locale -------------------------------------------------------------------
|
||||
|
||||
## Full locale string (e.g. "en_US").
|
||||
var locale: String = ""
|
||||
|
||||
## Language portion of the locale (e.g. "en").
|
||||
var locale_language: String = ""
|
||||
|
||||
|
||||
# -- File paths ---------------------------------------------------------------
|
||||
|
||||
## Base user data directory (user://).
|
||||
var user_data_dir: String = ""
|
||||
|
||||
## Directory where settings.cfg is written. Same as user_data_dir in Godot 4.
|
||||
var config_dir: String = ""
|
||||
|
||||
## Full path to AI benchmark cache (ai-dialogue-config.json).
|
||||
var benchmark_cache_path: String = ""
|
||||
|
||||
## Directory where the game binary lives (install location).
|
||||
var install_dir: String = ""
|
||||
|
||||
## Full path to the game executable.
|
||||
var executable_path: String = ""
|
||||
|
||||
## OS-provided cache directory, or user_data_dir/cache as fallback.
|
||||
var cache_dir: String = ""
|
||||
|
||||
## Directory where the bundled Gemma 2 model lives (derived from install_dir).
|
||||
var model_dir: String = ""
|
||||
|
||||
|
||||
# -- Lifecycle ----------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_init_paths()
|
||||
_init_hardware()
|
||||
refresh_memory()
|
||||
_poll_power()
|
||||
|
||||
var timer := Timer.new()
|
||||
timer.wait_time = POWER_POLL_INTERVAL
|
||||
timer.autostart = true
|
||||
timer.timeout.connect(_poll_power)
|
||||
add_child(timer)
|
||||
|
||||
|
||||
func _init_paths() -> void:
|
||||
user_data_dir = OS.get_user_data_dir()
|
||||
config_dir = user_data_dir
|
||||
benchmark_cache_path = "user://ai-dialogue-config.json"
|
||||
executable_path = OS.get_executable_path()
|
||||
install_dir = executable_path.get_base_dir()
|
||||
model_dir = install_dir + "/models"
|
||||
# get_cache_dir() may not exist on all Godot builds — use callv for safety.
|
||||
var os_cache: String = ""
|
||||
if OS.has_method("get_cache_dir"):
|
||||
var result: Variant = OS.callv("get_cache_dir", [])
|
||||
if result is String:
|
||||
os_cache = result
|
||||
cache_dir = os_cache if not os_cache.is_empty() else user_data_dir + "/cache"
|
||||
|
||||
|
||||
func _init_hardware() -> void:
|
||||
# -- CPU
|
||||
cpu_name = OS.get_processor_name()
|
||||
cpu_logical_cores = OS.get_processor_count()
|
||||
|
||||
# -- GPU (guarded: RenderingServer methods may return empty in headless mode)
|
||||
gpu_name = RenderingServer.get_video_adapter_name()
|
||||
gpu_vendor = RenderingServer.get_video_adapter_vendor()
|
||||
gpu_api_version = RenderingServer.get_video_adapter_api_version()
|
||||
gpu_driver_info = OS.get_video_adapter_driver_info()
|
||||
if RenderingServer.has_method("get_video_adapter_type"):
|
||||
var adapter_type: int = RenderingServer.callv("get_video_adapter_type", [])
|
||||
gpu_type = _map_adapter_type(adapter_type)
|
||||
|
||||
# -- Platform identity
|
||||
platform_name = OS.get_name()
|
||||
os_version = OS.get_version()
|
||||
if OS.has_method("get_distribution_name"):
|
||||
var dist: Variant = OS.callv("get_distribution_name", [])
|
||||
if dist is String:
|
||||
distribution = dist
|
||||
is_sandboxed = OS.is_sandboxed()
|
||||
|
||||
# -- Display
|
||||
screen_count = DisplayServer.get_screen_count()
|
||||
screen_size = DisplayServer.screen_get_size()
|
||||
screen_dpi = DisplayServer.screen_get_dpi()
|
||||
var raw_rate: float = DisplayServer.screen_get_refresh_rate()
|
||||
screen_refresh_rate = raw_rate if raw_rate >= 0.0 else 60.0
|
||||
display_scale = _compute_display_scale()
|
||||
|
||||
# -- Locale
|
||||
locale = OS.get_locale()
|
||||
locale_language = OS.get_locale_language()
|
||||
|
||||
|
||||
## Map RenderingDevice.DeviceType int to a readable string.
|
||||
## Values: 0=OTHER, 1=INTEGRATED, 2=DISCRETE, 3=VIRTUAL, 4=CPU.
|
||||
static func _map_adapter_type(adapter_type: int) -> String:
|
||||
match adapter_type:
|
||||
1: return "integrated"
|
||||
2: return "discrete"
|
||||
3: return "virtual"
|
||||
4: return "cpu"
|
||||
_: return "other"
|
||||
|
||||
|
||||
## Compute display scale for the primary screen.
|
||||
## macOS reports via screen_get_scale(); all others fall back to dpi / 96.0.
|
||||
func _compute_display_scale() -> float:
|
||||
if platform_name == "macOS" and DisplayServer.has_method("screen_get_scale"):
|
||||
var scale: Variant = DisplayServer.callv("screen_get_scale", [])
|
||||
if scale is float and scale > 0.0:
|
||||
return scale
|
||||
return float(screen_dpi) / 96.0
|
||||
|
||||
|
||||
# -- Memory API ---------------------------------------------------------------
|
||||
|
||||
## Refresh free/total memory readings from the OS. Call before read if freshness matters.
|
||||
func refresh_memory() -> void:
|
||||
var mem := OS.get_memory_info()
|
||||
# "available" = Linux MemAvailable / Windows ullAvailPhys (includes reclaimable pages).
|
||||
# Fall back to "free" on platforms that don't provide "available".
|
||||
free_memory_mb = float(mem.get("available", mem.get("free", 0))) / (1024.0 * 1024.0)
|
||||
total_memory_mb = float(mem.get("physical", 0)) / (1024.0 * 1024.0)
|
||||
|
||||
|
||||
# -- Power API ----------------------------------------------------------------
|
||||
|
||||
## Classify an OS power_state integer into a human-readable string.
|
||||
## 1 (ON_BATTERY) → "battery", 2/3/4 (plugged) → "plugged", 0 → "unknown".
|
||||
static func classify_power_state(power_state: int) -> String:
|
||||
match power_state:
|
||||
_POWER_STATE_ON_BATTERY:
|
||||
return "battery"
|
||||
_POWER_STATE_NO_BATTERY, _POWER_STATE_CHARGING, _POWER_STATE_CHARGED:
|
||||
return "plugged"
|
||||
_:
|
||||
return "unknown"
|
||||
|
||||
|
||||
## True only when the OS reports the device is on battery — used by Layer 3 suspend.
|
||||
static func should_suspend_inference(power_state: int) -> bool:
|
||||
return power_state == _POWER_STATE_ON_BATTERY
|
||||
|
||||
|
||||
## Read current OS power state integer. Returns 0 (UNKNOWN) if unavailable.
|
||||
## Uses callv() to defer method resolution to runtime — avoids compile errors on builds
|
||||
## where OS.get_power_info() is not available (e.g. headless).
|
||||
func current_power_state() -> int:
|
||||
# Try Godot 4.x: OS.get_power_info() → {"power_state": int, "percent": int, ...}
|
||||
if OS.has_method("get_power_info"):
|
||||
var info: Variant = OS.callv("get_power_info", [])
|
||||
if info is Dictionary:
|
||||
return int(info.get("power_state", _POWER_STATE_UNKNOWN))
|
||||
# Fallback: unknown power state → treat as FULL (no inference suspension).
|
||||
return _POWER_STATE_UNKNOWN
|
||||
|
||||
|
||||
func _poll_power() -> void:
|
||||
# Read raw state + battery percent from OS.
|
||||
raw_power_state = _POWER_STATE_UNKNOWN
|
||||
battery_percent = -1
|
||||
if OS.has_method("get_power_info"):
|
||||
var info: Variant = OS.callv("get_power_info", [])
|
||||
if info is Dictionary:
|
||||
raw_power_state = int(info.get("power_state", _POWER_STATE_UNKNOWN))
|
||||
if raw_power_state == _POWER_STATE_ON_BATTERY:
|
||||
battery_percent = int(info.get("percent", -1))
|
||||
|
||||
var new_profile: PowerProfile = PowerProfile.BATTERY \
|
||||
if raw_power_state == _POWER_STATE_ON_BATTERY \
|
||||
else PowerProfile.FULL
|
||||
if new_profile != power_profile:
|
||||
var old_profile := power_profile
|
||||
power_profile = new_profile
|
||||
power_profile_changed.emit(int(old_profile), int(new_profile))
|
||||
|
||||
|
||||
# -- Diagnostics --------------------------------------------------------------
|
||||
|
||||
## Returns all platform properties as a flat dictionary for inclusion in bug reports.
|
||||
func get_diagnostics() -> Dictionary:
|
||||
return {
|
||||
# Power
|
||||
"power_profile": int(power_profile),
|
||||
"raw_power_state": raw_power_state,
|
||||
"battery_percent": battery_percent,
|
||||
# Memory
|
||||
"free_memory_mb": free_memory_mb,
|
||||
"total_memory_mb": total_memory_mb,
|
||||
# CPU
|
||||
"cpu_name": cpu_name,
|
||||
"cpu_logical_cores": cpu_logical_cores,
|
||||
# GPU
|
||||
"gpu_name": gpu_name,
|
||||
"gpu_vendor": gpu_vendor,
|
||||
"gpu_api_version": gpu_api_version,
|
||||
"gpu_type": gpu_type,
|
||||
"gpu_driver_info": Array(gpu_driver_info),
|
||||
# Platform
|
||||
"platform_name": platform_name,
|
||||
"os_version": os_version,
|
||||
"distribution": distribution,
|
||||
"is_sandboxed": is_sandboxed,
|
||||
# Display
|
||||
"screen_count": screen_count,
|
||||
"screen_size": {"x": screen_size.x, "y": screen_size.y},
|
||||
"screen_dpi": screen_dpi,
|
||||
"screen_refresh_rate": screen_refresh_rate,
|
||||
"display_scale": display_scale,
|
||||
# Locale
|
||||
"locale": locale,
|
||||
"locale_language": locale_language,
|
||||
# Paths
|
||||
"user_data_dir": user_data_dir,
|
||||
"config_dir": config_dir,
|
||||
"benchmark_cache_path": benchmark_cache_path,
|
||||
"install_dir": install_dir,
|
||||
"executable_path": executable_path,
|
||||
"cache_dir": cache_dir,
|
||||
"model_dir": model_dir,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://platform_info_sr
|
||||
@@ -0,0 +1,184 @@
|
||||
extends Node
|
||||
## D-085 (#258): Game session lifecycle manager.
|
||||
## Creates per-game save directories on New Game, resumes existing sessions,
|
||||
## and handles quit-to-menu flow with save confirmation.
|
||||
##
|
||||
## All save dirs live under user://saves/<game-id>/ where game-id is
|
||||
## <YYYYMMDD>-<HHMMSS>-<hex6> (e.g. "20260225-143022-a7b3f1").
|
||||
|
||||
const SAVES_DIR := "user://saves/"
|
||||
const GAME_SCENE := "res://scenes/main.tscn"
|
||||
const MENU_SCENE := "res://scenes/main_menu.tscn"
|
||||
|
||||
var _quit_dialog: ConfirmationDialog = null
|
||||
|
||||
|
||||
## Generate a new game-id, create its save directory, and activate the session.
|
||||
## Returns the new game-id string.
|
||||
func new_game() -> String:
|
||||
var now := Time.get_datetime_dict_from_system()
|
||||
var timestamp := "%04d%02d%02d-%02d%02d%02d" % [
|
||||
now.year, now.month, now.day,
|
||||
now.hour, now.minute, now.second,
|
||||
]
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF)
|
||||
var game_id := "%s-%s" % [timestamp, hex_seed]
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var err := DirAccess.make_dir_recursive_absolute(save_path)
|
||||
if err != OK:
|
||||
push_error("SessionManager: failed to create save dir %s: %s" % [
|
||||
save_path, error_string(err)])
|
||||
return ""
|
||||
GameState.current_game_id = game_id
|
||||
|
||||
# #175: Generate world_seed for deterministic simulation (D-010, D-029).
|
||||
# Combines two randi() calls (u32 each) into 63-bit entropy range.
|
||||
# Mask bit 31 of the upper word before shifting to prevent signed overflow:
|
||||
# GDScript int is i64 — if bit 63 is set, MessagePack encodes as negative,
|
||||
# and Rust rmp_serde rejects negative values when deserializing as u64.
|
||||
GameState.world_seed = ((rng.randi() & 0x7FFFFFFF) << 32) | rng.randi()
|
||||
|
||||
# Persist world_seed to save directory so resume_game() can restore it.
|
||||
# Without this, loaded sessions would send seed=0, breaking D-010 determinism.
|
||||
_write_seed_file(save_path, GameState.world_seed)
|
||||
|
||||
return game_id
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
## Restores world_seed and character_archetype 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).
|
||||
## Returns Array of {game_id: String, modified_time: int, newest_save: String}.
|
||||
func list_game_dirs() -> Array:
|
||||
var dir := DirAccess.open(SAVES_DIR)
|
||||
if dir == null:
|
||||
return []
|
||||
var results: Array = []
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if dir.current_is_dir() and not entry.begins_with("."):
|
||||
var dir_path := SAVES_DIR + entry + "/"
|
||||
var newest_save := _find_newest_save(dir_path)
|
||||
var mtime: int = 0
|
||||
if newest_save != "":
|
||||
mtime = FileAccess.get_modified_time(dir_path + newest_save)
|
||||
results.append({
|
||||
"game_id": entry,
|
||||
"modified_time": mtime,
|
||||
"newest_save": newest_save,
|
||||
})
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return a.modified_time > b.modified_time)
|
||||
return results
|
||||
|
||||
|
||||
## Show "Save before quitting?" confirmation dialog, then return to main menu.
|
||||
## #554: The actual F5 save will be wired here once server supports SaveCommand.
|
||||
func quit_to_menu() -> void:
|
||||
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||
return # Dialog already open
|
||||
_quit_dialog = ConfirmationDialog.new()
|
||||
_quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit")
|
||||
_quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes")
|
||||
_quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no")
|
||||
get_tree().root.add_child(_quit_dialog)
|
||||
_quit_dialog.confirmed.connect(_do_quit_to_menu)
|
||||
_quit_dialog.canceled.connect(_cleanup_quit_dialog)
|
||||
_quit_dialog.popup_centered()
|
||||
|
||||
|
||||
func _do_quit_to_menu() -> void:
|
||||
_cleanup_quit_dialog()
|
||||
# #554: Trigger quicksave before navigating to menu.
|
||||
# send_input() buffers the command — defer scene change by one frame so
|
||||
# SimBridge._process() flushes the outbound buffer before teardown.
|
||||
if not GameState.current_game_id.is_empty():
|
||||
var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav"
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.SAVE_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": path},
|
||||
})
|
||||
GameState.current_game_id = ""
|
||||
_navigate_to_menu.call_deferred()
|
||||
else:
|
||||
GameState.current_game_id = ""
|
||||
get_tree().change_scene_to_file(MENU_SCENE)
|
||||
|
||||
|
||||
func _navigate_to_menu() -> void:
|
||||
get_tree().change_scene_to_file(MENU_SCENE)
|
||||
|
||||
|
||||
func _cleanup_quit_dialog() -> void:
|
||||
if _quit_dialog != null and is_instance_valid(_quit_dialog):
|
||||
_quit_dialog.queue_free()
|
||||
_quit_dialog = null
|
||||
|
||||
|
||||
## Write world_seed to a file in the save directory for session persistence.
|
||||
func _write_seed_file(save_path: String, seed: int) -> void:
|
||||
var file := FileAccess.open(save_path + "world_seed", FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("SessionManager: failed to write seed file: %s" % error_string(FileAccess.get_open_error()))
|
||||
return
|
||||
file.store_64(seed)
|
||||
|
||||
|
||||
## Read world_seed from save directory. Returns 0 if file missing (legacy saves).
|
||||
## Masks the sign bit on read: save files written before the signed-overflow fix
|
||||
## may contain negative i64 values that Rust rmp_serde rejects as u64.
|
||||
func _read_seed_file(save_path: String) -> int:
|
||||
var file := FileAccess.open(save_path + "world_seed", FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("SessionManager: no seed file in %s — using seed=0 (legacy save)" % save_path)
|
||||
return 0
|
||||
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:
|
||||
return ""
|
||||
var best_name := ""
|
||||
var best_time: int = 0
|
||||
dir.list_dir_begin()
|
||||
var entry := dir.get_next()
|
||||
while entry != "":
|
||||
if not dir.current_is_dir() and entry.ends_with(".sav"):
|
||||
var mtime := FileAccess.get_modified_time(dir_path + entry)
|
||||
if mtime > best_time:
|
||||
best_time = mtime
|
||||
best_name = entry
|
||||
entry = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
return best_name
|
||||
@@ -0,0 +1 @@
|
||||
uid://b357ok64jc8vp
|
||||
@@ -1,17 +1,11 @@
|
||||
extends Node
|
||||
|
||||
# Connection states
|
||||
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
|
||||
enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR }
|
||||
|
||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
||||
var _test_tick: int = 0
|
||||
var _test_player_pos: Vector2i = Vector2i(10, 10)
|
||||
var _test_facing: String = "North"
|
||||
var _test_input_queue: Array = [] # Queued actions for test mode
|
||||
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
|
||||
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
|
||||
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
|
||||
var harness: TestHarness = null # Test simulation (D-020: game logic lives outside production client)
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
@@ -27,23 +21,68 @@ const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts
|
||||
var _connect_retries: int = 0
|
||||
var _retry_timer: float = 0.0
|
||||
|
||||
# Handshake state (#556)
|
||||
const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds
|
||||
var _handshake_start_usec: int = 0
|
||||
|
||||
# Signals
|
||||
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
|
||||
signal snapshot_received(snapshot: Dictionary)
|
||||
signal handshake_complete(protocol_version: int)
|
||||
signal handshake_failed(reason: String)
|
||||
|
||||
func _ready() -> void:
|
||||
if test_mode:
|
||||
harness = TestHarness.new()
|
||||
print("SimBridge: Running in test mode (dynamic snapshot)")
|
||||
|
||||
# Reset test state — call before tests that use _test_snapshot()
|
||||
|
||||
# -- Test mode proxy API (backward compat for 13+ test files) ------------------
|
||||
|
||||
func reset_test_state() -> void:
|
||||
_test_tick = 0
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_facing = "North"
|
||||
_test_input_queue.clear()
|
||||
_test_in_dialogue = false
|
||||
_test_gauntlet_mode = false
|
||||
_test_npc_relationship = "Unknown"
|
||||
if harness: harness.reset()
|
||||
|
||||
func _test_snapshot() -> Dictionary:
|
||||
return harness.snapshot()
|
||||
|
||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
return harness.has_los(from, to)
|
||||
|
||||
var _test_tick: int:
|
||||
get: return harness.tick if harness else 0
|
||||
set(v):
|
||||
if harness: harness.tick = v
|
||||
|
||||
var _test_player_pos: Vector2i:
|
||||
get: return harness.player_pos if harness else Vector2i.ZERO
|
||||
set(v):
|
||||
if harness: harness.player_pos = v
|
||||
|
||||
var _test_facing: String:
|
||||
get: return harness.facing if harness else "North"
|
||||
set(v):
|
||||
if harness: harness.facing = v
|
||||
|
||||
var _test_in_dialogue: bool:
|
||||
get: return harness.in_dialogue if harness else false
|
||||
set(v):
|
||||
if harness: harness.in_dialogue = v
|
||||
|
||||
var _test_gauntlet_mode: bool:
|
||||
get: return harness.gauntlet_mode if harness else false
|
||||
set(v):
|
||||
if harness: harness.gauntlet_mode = v
|
||||
|
||||
var _test_npc_relationship: String:
|
||||
get: return harness.npc_relationship if harness else "Unknown"
|
||||
set(v):
|
||||
if harness: harness.npc_relationship = v
|
||||
|
||||
var _test_input_queue: Array:
|
||||
get: return harness.input_queue if harness else []
|
||||
|
||||
|
||||
# -- Connection lifecycle ------------------------------------------------------
|
||||
|
||||
# Change connection state and emit signal
|
||||
func _set_state(new_state: ConnectionState) -> void:
|
||||
@@ -66,8 +105,13 @@ func connect_to_sim() -> void:
|
||||
# Spawn server subprocess
|
||||
if not server_path.is_empty():
|
||||
_server = ServerProcess.new()
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
|
||||
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876").
|
||||
# D-085 (#258): pass --game-id <id> so server logs use the same session identifier.
|
||||
var args := ["127.0.0.1:" + str(server_port)]
|
||||
var game_id: String = GameState.current_game_id
|
||||
if not game_id.is_empty():
|
||||
args.append_array(["--game-id", game_id])
|
||||
var pid := _server.start(server_path, args)
|
||||
if pid <= 0:
|
||||
push_error("SimBridge: failed to start server")
|
||||
_set_state(ConnectionState.ERROR)
|
||||
@@ -121,7 +165,8 @@ func _process(delta: float) -> void:
|
||||
_bridge.poll()
|
||||
match _bridge.get_status():
|
||||
StreamPeerTCP.STATUS_CONNECTED:
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
_handshake_start_usec = Time.get_ticks_usec()
|
||||
_set_state(ConnectionState.HANDSHAKING)
|
||||
StreamPeerTCP.STATUS_CONNECTING:
|
||||
pass # Still connecting, wait
|
||||
StreamPeerTCP.STATUS_ERROR:
|
||||
@@ -134,6 +179,88 @@ func _process(delta: float) -> void:
|
||||
_bridge = null # Reset and retry
|
||||
return
|
||||
|
||||
# HANDSHAKING state: read first framed message, validate HandshakeMessage (#556)
|
||||
if state == ConnectionState.HANDSHAKING:
|
||||
if _bridge == null:
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
_bridge.poll()
|
||||
|
||||
# Check connection dropped during handshake
|
||||
var bridge_status := _bridge.get_status()
|
||||
if bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE:
|
||||
var reason := "Connection dropped during handshake"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge = null
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Check timeout
|
||||
if Time.get_ticks_usec() - _handshake_start_usec > HANDSHAKE_TIMEOUT_USEC:
|
||||
var reason := "Handshake timeout: no message received within 5 seconds"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Try to read first message
|
||||
var msg := _bridge.poll_message()
|
||||
if msg.is_empty():
|
||||
return # Not ready yet, continue polling
|
||||
|
||||
# Decode HandshakeMessage: { "protocol_version": N }
|
||||
var decoded: Variant = Messagepack.decode(msg)
|
||||
if decoded.status != null or not (decoded.value is Dictionary) \
|
||||
or not decoded.value.has("protocol_version"):
|
||||
var reason := "Handshake decode failed: malformed HandshakeMessage"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
var server_version: int = decoded.value["protocol_version"]
|
||||
if server_version != Protocol.PROTOCOL_VERSION:
|
||||
var reason := "Protocol version mismatch: server=%d, client=%d" % [
|
||||
server_version, Protocol.PROTOCOL_VERSION]
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# Send startup message with world_seed (#175, D-010/D-029).
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed, GameState.character_archetype)
|
||||
if startup_bytes.size() > 0:
|
||||
var send_err := _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)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
else:
|
||||
var reason := "Failed to encode startup message"
|
||||
push_error("SimBridge: %s" % reason)
|
||||
handshake_failed.emit(reason)
|
||||
_bridge.disconnect_from_server()
|
||||
_set_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
handshake_complete.emit(server_version)
|
||||
_set_state(ConnectionState.CONNECTED)
|
||||
# #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).
|
||||
_outbound_buffer.append({
|
||||
"tick": 0,
|
||||
"action_name": "RequestAllSettings",
|
||||
})
|
||||
return
|
||||
|
||||
if _bridge == null:
|
||||
return
|
||||
|
||||
@@ -170,9 +297,13 @@ func _process(delta: float) -> void:
|
||||
push_warning("SimBridge: connection lost")
|
||||
_set_state(ConnectionState.DISCONNECTED)
|
||||
|
||||
|
||||
# -- Input / snapshot ----------------------------------------------------------
|
||||
|
||||
# Send input to simulation server.
|
||||
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
|
||||
# In test mode, inputs are silently dropped. In live mode, encoded and buffered for transport.
|
||||
# In test mode, inputs are delegated to the test harness.
|
||||
# In live mode, encoded and buffered for transport.
|
||||
# Returns OK on success, or an error code on failure.
|
||||
func send_input(player_input: Dictionary) -> Error:
|
||||
if state != ConnectionState.CONNECTED:
|
||||
@@ -182,25 +313,20 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
var wire_name: String = action_enum_to_wire(action)
|
||||
if not wire_name.is_empty():
|
||||
if wire_name == "SetFacing":
|
||||
# D-054: Use action_data.facing from the input dict, not InputMapper global
|
||||
var facing: String = ""
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data is Dictionary:
|
||||
facing = str(action_data.get("facing", ""))
|
||||
if not facing.is_empty():
|
||||
_test_facing = facing
|
||||
harness.process_facing(facing)
|
||||
else:
|
||||
_test_input_queue.append(wire_name)
|
||||
harness.process_input(wire_name)
|
||||
return OK
|
||||
var action_name := action_enum_to_wire(player_input.get("action", -1))
|
||||
if action_name.is_empty():
|
||||
# action_enum_to_wire already emits push_warning for invalid actions
|
||||
return ERR_INVALID_PARAMETER
|
||||
# Use the server's current tick so drain_for_tick processes this input immediately.
|
||||
# The client-side timestamp_msec is only useful for ordering within a frame.
|
||||
var tick: int = GameState.current_tick
|
||||
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
||||
# Data variants (e.g. UsePerceptionMode) carry payload
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data != null:
|
||||
entry["action_data"] = action_data
|
||||
@@ -208,13 +334,13 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
return OK
|
||||
|
||||
# Poll for snapshot from simulation.
|
||||
# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any).
|
||||
# In test mode delegates to test harness. In live mode, returns the last decoded snapshot.
|
||||
func poll_snapshot() -> Variant:
|
||||
if state != ConnectionState.CONNECTED:
|
||||
return null
|
||||
|
||||
if test_mode:
|
||||
var snapshot = _test_snapshot()
|
||||
var snapshot = harness.snapshot()
|
||||
snapshot_received.emit(snapshot)
|
||||
return snapshot
|
||||
|
||||
@@ -252,6 +378,12 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
if old_conv_ended.size() > 0:
|
||||
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
|
||||
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
|
||||
# #554: Carry forward save/load result (one-shot, consumed by main.gd)
|
||||
if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null:
|
||||
snapshot["save_result"] = _last_snapshot["save_result"]
|
||||
# #646: Carry forward settings_response (one-shot, consumed by game_state apply_snapshot)
|
||||
if snapshot.get("settings_response") == null and _last_snapshot.get("settings_response") != null:
|
||||
snapshot["settings_response"] = _last_snapshot["settings_response"]
|
||||
_last_snapshot = snapshot
|
||||
|
||||
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
||||
@@ -260,6 +392,9 @@ func drain_outbound() -> Array[Dictionary]:
|
||||
_outbound_buffer.clear()
|
||||
return inputs
|
||||
|
||||
|
||||
# -- Wire protocol mapping -----------------------------------------------------
|
||||
|
||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
||||
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||
static func action_enum_to_wire(action: int) -> String:
|
||||
@@ -286,319 +421,18 @@ static func action_enum_to_wire(action: int) -> String:
|
||||
return "SetFacing" # D-054: facing octant update (no movement)
|
||||
InputMapper.Action.TELEPORT_HUB:
|
||||
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
|
||||
InputMapper.Action.SAVE_GAME:
|
||||
return "SaveGame" # #554: F5 quicksave (D-085)
|
||||
InputMapper.Action.LOAD_GAME:
|
||||
return "LoadGame" # #554: F6 quickload (D-085)
|
||||
InputMapper.Action.DEBUG_COMMAND:
|
||||
return "DebugCommand" # #581: debug console command dispatch
|
||||
InputMapper.Action.CHANGE_SETTINGS:
|
||||
return "ChangeSettings" # #646: persist setting to server SQLite (D-138)
|
||||
InputMapper.Action.REQUEST_ALL_SETTINGS:
|
||||
return "RequestAllSettings" # #646: unit variant — server sends full settings dump
|
||||
InputMapper.Action.DELETE_SETTING:
|
||||
return "DeleteSetting" # #646: struct variant — delete setting by key
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
|
||||
# Dynamic test snapshot — processes queued inputs to move player, generates
|
||||
# visibility based on current position. Matches Protocol.decode_snapshot() format.
|
||||
# NOTE: Test coordinate space (player at 10,10; NPC at 12,9; wall at 12,10)
|
||||
# is intentionally decoupled from the E2E proof room (player at 16,16; NPC at
|
||||
# 16,13; wall at 16,14). This ensures standalone tests don't depend on server
|
||||
# map layout and can exercise the rendering pipeline independently.
|
||||
func _test_snapshot() -> Dictionary:
|
||||
_test_tick += 1
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in _test_input_queue:
|
||||
if action_name == "TeleportToHub":
|
||||
# #501: Reset to hub spawn position, clear dialogue
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_in_dialogue = false
|
||||
continue
|
||||
if action_name == "Interact":
|
||||
# Mock dialogue trigger (#434): if near NPC, start dialogue
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y)
|
||||
if dist <= 2 and _test_has_los(_test_player_pos, npc_pos):
|
||||
_test_in_dialogue = true
|
||||
continue
|
||||
var delta := _action_to_delta(action_name)
|
||||
var new_pos := _test_player_pos + delta
|
||||
if _test_is_walkable(new_pos):
|
||||
_test_player_pos = new_pos
|
||||
if delta != Vector2i.ZERO:
|
||||
# Walk-away dismisses dialogue (D-064)
|
||||
if _test_in_dialogue:
|
||||
_test_in_dialogue = false
|
||||
_test_input_queue.clear()
|
||||
|
||||
var px := _test_player_pos.x
|
||||
var py := _test_player_pos.y
|
||||
|
||||
# Build entities — player always visible
|
||||
var entities: Array = [{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Player", "data": null },
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
|
||||
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
||||
if npc_dist <= 4 and _test_has_los(Vector2i(px, py), npc_pos):
|
||||
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
||||
entities.append({
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": _test_npc_relationship,
|
||||
})
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
var nearby: Array = []
|
||||
if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos):
|
||||
nearby.append({
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
})
|
||||
|
||||
# v5: monologue on first tick (#414)
|
||||
var monologue: Variant = null
|
||||
if _test_tick == 1:
|
||||
monologue = {
|
||||
"id": "test_enter_001",
|
||||
"text": "Sova Transit District. Population twelve thousand and change.",
|
||||
"duration_seconds": 5.0,
|
||||
}
|
||||
|
||||
# v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC
|
||||
# Sustained: dialogue persists across ticks while _test_in_dialogue is true.
|
||||
# Movement (walk-away) clears it. Client consume-once guards against re-show.
|
||||
# Options: structured {text, response_id, priority} per #435.
|
||||
var dialogue: Variant = null
|
||||
if _test_in_dialogue:
|
||||
dialogue = {
|
||||
"npc_name": "Kael",
|
||||
"npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options": [
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
||||
],
|
||||
}
|
||||
|
||||
# v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity
|
||||
# Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks.
|
||||
# Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters).
|
||||
var pending_recs: Array = []
|
||||
var cycle_pos := _test_tick % 12
|
||||
if cycle_pos < 6:
|
||||
var total_delay := 6
|
||||
var remaining := total_delay - cycle_pos
|
||||
pending_recs.append({
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
# Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3.
|
||||
# Conversation ends after 6 exchanges (~30 ticks).
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks:
|
||||
var conv_index := (_test_tick - conv_start) / conv_tick_interval
|
||||
var within_tick := (_test_tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
elif _test_tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": _test_tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": _test_tick * 10,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": "Full",
|
||||
},
|
||||
"player_facing": _test_facing,
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": entities,
|
||||
"tiles": _test_tiles(),
|
||||
"visible_tiles": _test_visible_tiles(),
|
||||
"visible_positions": _test_visible_positions(),
|
||||
"nearby_interactions": nearby,
|
||||
"current_monologue": monologue,
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": _test_gauntlet_mode,
|
||||
"conversation_events": conv_events,
|
||||
"conversation_ended": conv_ended,
|
||||
}
|
||||
|
||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
||||
func _test_tiles() -> Array:
|
||||
var tiles: Array = []
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(room_x, room_x + room_w):
|
||||
for y in range(room_y, room_y + room_h):
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var tile_type: String
|
||||
if is_edge:
|
||||
# Door on the south wall, center
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
tile_type = "door"
|
||||
else:
|
||||
tile_type = "wall"
|
||||
else:
|
||||
tile_type = "floor"
|
||||
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
||||
|
||||
# Corridor south of the door
|
||||
var door_x := room_x + room_w / 2
|
||||
for y in range(room_y + room_h, room_y + room_h + 4):
|
||||
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
||||
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
||||
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
||||
|
||||
return tiles
|
||||
|
||||
# Test visible tiles with visibility sectors (v2 format)
|
||||
# Tiles ahead of the player are Forward, others Peripheral.
|
||||
func _test_visible_tiles() -> Array:
|
||||
var vtiles: Array = []
|
||||
var px := _test_player_pos.x
|
||||
var py := _test_player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
var sector: String = "Forward" if y <= py else "Peripheral"
|
||||
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector})
|
||||
return vtiles
|
||||
|
||||
# Test visibility: tiles within radius 4 of player, inside room bounds
|
||||
func _test_visible_positions() -> Array:
|
||||
var positions: Array = []
|
||||
var px := _test_player_pos.x
|
||||
var py := _test_player_pos.y
|
||||
var radius := 4
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
positions.append({"x": x, "y": y})
|
||||
return positions
|
||||
|
||||
|
||||
# -- Test mode helpers --
|
||||
|
||||
const _TEST_WALLS: Array = [
|
||||
# Room walls (8x8 room from (7,7) to (14,14))
|
||||
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
||||
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
||||
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
||||
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
||||
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
||||
Vector2i(7,12), Vector2i(7,13),
|
||||
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
||||
Vector2i(14,12), Vector2i(14,13),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
|
||||
func _test_is_walkable(pos: Vector2i) -> bool:
|
||||
return not _TEST_WALLS.has(pos)
|
||||
|
||||
# Simple LOS check — blocked if a wall tile sits between start and end
|
||||
func _test_has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
# Bresenham-lite: check tiles along the line
|
||||
var dx := absi(to.x - from.x)
|
||||
var dy := absi(to.y - from.y)
|
||||
var sx := 1 if from.x < to.x else -1
|
||||
var sy := 1 if from.y < to.y else -1
|
||||
var err := dx - dy
|
||||
var cx := from.x
|
||||
var cy := from.y
|
||||
while true:
|
||||
if cx == to.x and cy == to.y:
|
||||
return true
|
||||
if Vector2i(cx, cy) != from and not _test_is_walkable(Vector2i(cx, cy)):
|
||||
return false
|
||||
var e2 := 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
cx += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
cy += sy
|
||||
return true
|
||||
|
||||
static func _action_to_delta(action_name: String) -> Vector2i:
|
||||
match action_name:
|
||||
"MoveNorth": return Vector2i(0, -1)
|
||||
"MoveNortheast": return Vector2i(1, -1)
|
||||
"MoveEast": return Vector2i(1, 0)
|
||||
"MoveSoutheast": return Vector2i(1, 1)
|
||||
"MoveSouth": return Vector2i(0, 1)
|
||||
"MoveSouthwest": return Vector2i(-1, 1)
|
||||
"MoveWest": return Vector2i(-1, 0)
|
||||
"MoveNorthwest": return Vector2i(-1, -1)
|
||||
_: return Vector2i.ZERO
|
||||
|
||||
static func _delta_to_facing(delta: Vector2i) -> String:
|
||||
match delta:
|
||||
Vector2i(0, -1): return "North"
|
||||
Vector2i(1, -1): return "Northeast"
|
||||
Vector2i(1, 0): return "East"
|
||||
Vector2i(1, 1): return "Southeast"
|
||||
Vector2i(0, 1): return "South"
|
||||
Vector2i(-1, 1): return "Southwest"
|
||||
Vector2i(-1, 0): return "West"
|
||||
Vector2i(-1, -1): return "Northwest"
|
||||
_: return "North"
|
||||
|
||||
@@ -49,44 +49,6 @@ func reload() -> void:
|
||||
|
||||
## Parse YAML with arbitrary nesting depth.
|
||||
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
|
||||
## Delegates to YamlParser.parse_flat() (#560).
|
||||
static func _parse_yaml(text: String) -> Dictionary:
|
||||
var strings := {}
|
||||
var stack: Array = [] # [[indent, key], ...]
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.begins_with("#"):
|
||||
continue
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
var colon_pos := content.find(":")
|
||||
if colon_pos < 0:
|
||||
continue
|
||||
var key := content.substr(0, colon_pos).strip_edges()
|
||||
var val := content.substr(colon_pos + 1).strip_edges()
|
||||
# Trailing comment without a value — treat as section header
|
||||
if val.begins_with("#"):
|
||||
val = ""
|
||||
# Pop sections at same or deeper indent
|
||||
while stack.size() > 0 and stack.back()[0] >= indent:
|
||||
stack.pop_back()
|
||||
if val.is_empty():
|
||||
# Section header — push onto stack
|
||||
stack.push_back([indent, key])
|
||||
else:
|
||||
# Leaf value — extract from quotes or strip inline comment
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
val = val.substr(1, end_quote - 1)
|
||||
else:
|
||||
val = val.substr(1)
|
||||
else:
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
var dotted_key := ""
|
||||
for entry in stack:
|
||||
dotted_key += entry[1] + "."
|
||||
dotted_key += key
|
||||
strings[dotted_key] = val
|
||||
return strings
|
||||
return YamlParser.parse_flat(text)
|
||||
|
||||
@@ -233,12 +233,7 @@ func _find_entity(entity_id: int) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
# -- YAML parsing (checklist-specific) -----------------------------------------
|
||||
# Handles the constrained checklist YAML format: top-level key:value pairs,
|
||||
# a conditions array of flat dictionaries. No nested arrays or anchors.
|
||||
#
|
||||
# Limitation: unquoted values containing " #" are truncated at the comment marker.
|
||||
# Use quoted strings ("value # with hash") if values must contain literal hashes.
|
||||
# -- YAML parsing --------------------------------------------------------------
|
||||
|
||||
func _load_checklist_file(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
@@ -252,103 +247,6 @@ func _load_checklist_file(path: String) -> Dictionary:
|
||||
return parse_checklist_yaml(text)
|
||||
|
||||
|
||||
## Delegates to YamlParser.parse() (#560).
|
||||
static func parse_checklist_yaml(text: String) -> Dictionary:
|
||||
var result := {}
|
||||
var conditions: Array = []
|
||||
var current_item: Dictionary = {}
|
||||
var in_conditions := false
|
||||
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||
continue
|
||||
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
|
||||
# Detect conditions: array header
|
||||
if content == "conditions:":
|
||||
in_conditions = true
|
||||
continue
|
||||
|
||||
if not in_conditions:
|
||||
# Top-level key: value
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
else:
|
||||
if content.begins_with("- "):
|
||||
# New array item — flush previous
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var rest := content.substr(2).strip_edges()
|
||||
var colon := rest.find(":")
|
||||
if colon >= 0:
|
||||
var key := rest.substr(0, colon).strip_edges()
|
||||
var val_str := rest.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent >= 2 and not current_item.is_empty():
|
||||
# Continuation of current array item
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent == 0:
|
||||
# Back to top level — shouldn't happen in valid checklist YAML
|
||||
in_conditions = false
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
|
||||
# Flush last item
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
|
||||
if not conditions.is_empty():
|
||||
result["conditions"] = conditions
|
||||
|
||||
return result
|
||||
|
||||
|
||||
static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
|
||||
# Strip inline comments (not inside quotes)
|
||||
if not val.begins_with("\""):
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
|
||||
# Quoted string
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
|
||||
# Boolean
|
||||
if val == "true":
|
||||
return true
|
||||
if val == "false":
|
||||
return false
|
||||
|
||||
# Float (contains decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
|
||||
# Integer
|
||||
if val.is_valid_int():
|
||||
return val.to_int()
|
||||
|
||||
# Plain string
|
||||
return val
|
||||
return YamlParser.parse(text)
|
||||
|
||||
@@ -96,6 +96,11 @@ const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
# two columns of text comfortably, leaves world game visible alongside.
|
||||
const DIALOGUE_MAX_WIDTH: int = 1200
|
||||
|
||||
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
|
||||
static func format_game_time(time_of_day: int) -> String:
|
||||
var clamped: int = clampi(time_of_day, 0, 1439)
|
||||
return "%02d:%02d" % [clamped / 60, clamped % 60]
|
||||
|
||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
|
||||
|
||||
@@ -14,9 +14,16 @@ extends Node2D
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
|
||||
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
|
||||
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
|
||||
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
|
||||
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
|
||||
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
|
||||
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
@@ -25,10 +32,12 @@ var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when s
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
||||
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
||||
var _known_triangle_ids: Dictionary = {} # #590: triangle_ids that have already fired the activation chime
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
||||
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
|
||||
|
||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
||||
|
||||
@@ -44,6 +53,17 @@ func _ready() -> void:
|
||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||
SimBridge.connect_to_sim()
|
||||
|
||||
# #257: If returning from main menu "Load Game" selection, defer dispatch until connected.
|
||||
# In test mode, connect_to_sim() sets CONNECTED synchronously — dispatch fires immediately.
|
||||
# In live mode, state is CONNECTING — signal handler dispatches once connected.
|
||||
if not GameState.pending_load_path.is_empty():
|
||||
if loading_screen:
|
||||
loading_screen.show_loading()
|
||||
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
|
||||
_dispatch_pending_load()
|
||||
else:
|
||||
SimBridge.connection_state_changed.connect(_on_sim_connected_for_load)
|
||||
|
||||
# Camera anchor: snap to player position before the first frame renders.
|
||||
# In test mode poll_snapshot() returns synchronously — position is set
|
||||
# immediately. In live mode the snapshot isn't available yet — _process
|
||||
@@ -61,11 +81,64 @@ func _ready() -> void:
|
||||
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
||||
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
# D-020 (#558): Decoupled signals — coordinator routes state changes.
|
||||
dialogue_box.dialogue_state_changed.connect(_on_dialogue_state_changed)
|
||||
dialogue_box.audio_dip_requested.connect(_on_audio_dip_requested)
|
||||
dialogue_box.audio_dip_cleared.connect(_on_audio_dip_cleared)
|
||||
|
||||
# #496: Print gauntlet session summary on disconnect
|
||||
if gauntlet_hud:
|
||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||
|
||||
# #559: Register snapshot dispatch handlers — replaces inline dispatch in _process().
|
||||
_router = SnapshotEventRouter.new()
|
||||
# Always-run: child nodes that update from GameState on every snapshot tick.
|
||||
if world_renderer:
|
||||
_router.register_always(world_renderer.update_from_state)
|
||||
_router.register_always(_propagate_insert_state)
|
||||
_router.register_always(_update_interaction_list)
|
||||
if inventory_grid:
|
||||
_router.register_always(inventory_grid.update_from_state)
|
||||
if stance_indicator:
|
||||
_router.register_always(stance_indicator.update_from_state)
|
||||
if fog_entities:
|
||||
_router.register_always(fog_entities.update_from_state)
|
||||
_router.register_always(_play_recognition_chimes)
|
||||
_router.register_always(_handle_triangle_crisis_events)
|
||||
if gauntlet_hud:
|
||||
_router.register_always(gauntlet_hud.update_from_state)
|
||||
if checklist_overlay:
|
||||
_router.register_always(checklist_overlay.update_from_state)
|
||||
if time_display:
|
||||
_router.register_always(time_display.update_from_state)
|
||||
if news_ticker:
|
||||
_router.register_always(news_ticker.update_from_state)
|
||||
if journal_panel:
|
||||
_router.register_always(journal_panel.update_from_state)
|
||||
if debug_overlay:
|
||||
_router.register_always(debug_overlay.update_from_state)
|
||||
_router.register_always(_play_close_sound_events)
|
||||
_router.register_always(_update_zone)
|
||||
_router.register_always(_update_listening_focus)
|
||||
_router.register_always(_consume_examine_result)
|
||||
# Keyed: consume methods guarded by specific snapshot fields.
|
||||
_router.register("current_monologue", _consume_monologue)
|
||||
_router.register("current_dialogue", _consume_dialogue)
|
||||
_router.register("conversation_events", _consume_conversation_events)
|
||||
_router.register("conversation_ended", _consume_conversation_ended)
|
||||
_router.register("dialogue_response", _consume_dialogue_response)
|
||||
_router.register("save_result", _consume_save_result)
|
||||
_router.register("debug_response", _consume_debug_response)
|
||||
|
||||
# #581: Wire settings_dialog debug console toggle → debug_console.set_enabled
|
||||
if settings_dialog and debug_console:
|
||||
settings_dialog.debug_console_toggled.connect(debug_console.set_enabled)
|
||||
|
||||
# #581 D-088: Wire debug console pause/unpause — sim must not advance during debug input
|
||||
if debug_console:
|
||||
debug_console.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
debug_console.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Main game loop: poll snapshot, apply state, flush input
|
||||
@@ -85,76 +158,9 @@ func _process(delta: float) -> void:
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
|
||||
# Update renderers with new state
|
||||
if world_renderer and world_renderer.has_method("update_from_state"):
|
||||
world_renderer.update_from_state()
|
||||
|
||||
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
|
||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list and interaction_list.has_method("set_insert_active"):
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
|
||||
# D-057: Update interaction list from game state
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
||||
if interaction_list and interaction_list.has_method("update_from_state"):
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list._hide()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
# D-065: Update inventory grid
|
||||
if inventory_grid and inventory_grid.has_method("update_from_state"):
|
||||
inventory_grid.update_from_state()
|
||||
|
||||
# D-053: Update stance indicator
|
||||
if stance_indicator and stance_indicator.has_method("update_from_state"):
|
||||
stance_indicator.update_from_state()
|
||||
|
||||
# D-059/D-060: Update fog entity visualization (#431)
|
||||
if fog_entities and fog_entities.has_method("update_from_state"):
|
||||
fog_entities.update_from_state()
|
||||
|
||||
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
|
||||
_play_recognition_chimes()
|
||||
|
||||
# #496: Update gauntlet HUD (room timer + personal bests)
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
|
||||
# #503: Update checklist overlay (auto-checklist progress tracking)
|
||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||
checklist_overlay.update_from_state()
|
||||
|
||||
# #511: Update debug overlay (F3 toggle, dev tool)
|
||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||
debug_overlay.update_from_state()
|
||||
|
||||
# D-018 #125: Play close-range sound events via positional 2D audio
|
||||
_play_close_sound_events()
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
|
||||
_update_zone()
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
|
||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
||||
_update_listening_focus()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
# D-061: Show dialogue if server sent one this tick (#434)
|
||||
_consume_dialogue()
|
||||
|
||||
# #535: Consume overheard conversation events and responses
|
||||
_consume_conversation_events()
|
||||
_consume_conversation_ended()
|
||||
_consume_dialogue_response()
|
||||
# #559: Dispatch snapshot to registered handlers (router pattern).
|
||||
# Always-run handlers update child nodes; keyed handlers fire for present fields.
|
||||
_router.dispatch(snapshot)
|
||||
|
||||
# Track camera to player (D-015: locked, fixed-north).
|
||||
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
||||
@@ -179,6 +185,19 @@ func _process(delta: float) -> void:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
continue
|
||||
# #264: J — client-only, toggle knowledge journal panel
|
||||
if input.action == InputMapper.Action.OPEN_JOURNAL:
|
||||
_toggle_journal()
|
||||
continue
|
||||
# #257: LOAD_GAME — send first, then show loading screen (avoids stuck overlay if send fails)
|
||||
if input.action == InputMapper.Action.LOAD_GAME:
|
||||
var err := SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
if err == OK and loading_screen:
|
||||
loading_screen.show_loading()
|
||||
elif err != OK:
|
||||
push_error("main.gd: LOAD_GAME send_input failed: %s" % error_string(err))
|
||||
continue
|
||||
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||
if input.action == InputMapper.Action.OPEN_MENU:
|
||||
if settings_dialog:
|
||||
@@ -223,6 +242,32 @@ func _process(delta: float) -> void:
|
||||
_pending_record_inputs.clear()
|
||||
|
||||
|
||||
# OQ-07 (#522): Propagate insert state to all z-layer-6 display nodes.
|
||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||
func _propagate_insert_state() -> void:
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer:
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list:
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt:
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
if minimap:
|
||||
minimap.set_insert_active(insert_state)
|
||||
|
||||
|
||||
# D-057: Update interaction list from game state.
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise.
|
||||
func _update_interaction_list() -> void:
|
||||
if not interaction_list:
|
||||
return
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list.hide_list()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
|
||||
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
|
||||
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
|
||||
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
|
||||
@@ -254,6 +299,21 @@ func _play_recognition_chimes() -> void:
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
|
||||
|
||||
# #590 D-072/D-089: Triangle activation consumer — fires sfx_monologue_chime_urgent once
|
||||
# per triangle_id. The tell_state on the activated NPC and subsequent proximity monologue
|
||||
# lines are the visible consequence (D-039 wow moment #2 "The Character's Eye").
|
||||
# No overlay is shown — the chime is the only client-side reaction (D-039 intent).
|
||||
func _handle_triangle_crisis_events() -> void:
|
||||
var events: Array = GameState.current_snapshot.get("triangle_crisis_events", [])
|
||||
for ev in events:
|
||||
if not ev is Dictionary or not ev.has("triangle_id"):
|
||||
continue
|
||||
var tid: int = ev.triangle_id
|
||||
if not _known_triangle_ids.has(tid):
|
||||
_known_triangle_ids[tid] = true
|
||||
AudioManager.play(AudioManager.CHIME_ACTIVATION, AudioManager.BUS_UI_SOUNDS)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||
# (extracted in apply_snapshot(), server-authoritative per D-020).
|
||||
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
|
||||
@@ -313,13 +373,17 @@ func _consume_dialogue() -> void:
|
||||
GameState.current_dialogue = null
|
||||
return
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
# #264: Close journal when dialogue opens (cannot be open simultaneously)
|
||||
if journal_panel and journal_panel.has_method("close"):
|
||||
journal_panel.close()
|
||||
var dlg: Dictionary = GameState.current_dialogue
|
||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
dlg.get("speech", ""),
|
||||
dlg.get("options", [])
|
||||
dlg.get("options", []),
|
||||
_last_dialogue_npc_id
|
||||
)
|
||||
GameState.current_dialogue = null
|
||||
|
||||
@@ -349,14 +413,48 @@ func _consume_dialogue_response() -> void:
|
||||
if GameState.dialogue_response == null or not dialogue_box:
|
||||
return
|
||||
var dr: Dictionary = GameState.dialogue_response
|
||||
# v0.1: falls back to _last_dialogue_npc_id if wire omits speaker_entity_id.
|
||||
# Edge case: fast re-engagement with a different NPC could misattribute — low probability.
|
||||
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
|
||||
var speaker_color_index: int = dr.get("speaker_color_index", -1)
|
||||
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
||||
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""))
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""), speaker_entity_id)
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
# #554/#257: Show save/load result notification; hide loading screen on load complete.
|
||||
func _consume_save_result() -> void:
|
||||
if GameState.save_result == null:
|
||||
return
|
||||
var result: Dictionary = GameState.save_result
|
||||
GameState.save_result = null # consume once
|
||||
# #257: Dismiss loading screen regardless of success/failure
|
||||
if loading_screen:
|
||||
loading_screen.hide_loading()
|
||||
var msg: String
|
||||
if result.get("success", false):
|
||||
if result.get("kind", "") == "save":
|
||||
msg = UIStrings.get_text("notifications.save_complete")
|
||||
else:
|
||||
msg = UIStrings.get_text("notifications.load_complete")
|
||||
else:
|
||||
if result.get("kind", "") == "save":
|
||||
msg = UIStrings.get_text("notifications.save_failed")
|
||||
else:
|
||||
msg = UIStrings.get_text("notifications.load_failed")
|
||||
if monologue_display:
|
||||
monologue_display.show_notification(msg)
|
||||
|
||||
|
||||
# #581: Forward debug_response from server to the debug console.
|
||||
func _consume_debug_response() -> void:
|
||||
if GameState.debug_response == null or not debug_console:
|
||||
return
|
||||
debug_console.append_response(GameState.debug_response)
|
||||
GameState.debug_response = null
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection → send to server
|
||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
@@ -408,12 +506,54 @@ func _on_dialogue_dismissed() -> void:
|
||||
})
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator handles dialogue state changes from dialogue_box.
|
||||
# Synchronous signal — GameState.dialogue_active updates same frame (D-064).
|
||||
func _on_dialogue_state_changed(active: bool) -> void:
|
||||
GameState.dialogue_active = active
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator routes audio dip requests from dialogue_box.
|
||||
func _on_audio_dip_requested(profile: String) -> void:
|
||||
AudioManager.apply_dip(profile)
|
||||
|
||||
|
||||
# D-020 (#558): Coordinator routes audio dip clear from dialogue_box.
|
||||
func _on_audio_dip_cleared() -> void:
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# #496: Finalize gauntlet stats on disconnect
|
||||
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
gauntlet_hud.finalize()
|
||||
|
||||
|
||||
# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED.
|
||||
# pending_load_path is set by main_menu.gd before scene change.
|
||||
func _on_sim_connected_for_load(_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state != SimBridge.ConnectionState.CONNECTED:
|
||||
return
|
||||
if SimBridge.connection_state_changed.is_connected(_on_sim_connected_for_load):
|
||||
SimBridge.connection_state_changed.disconnect(_on_sim_connected_for_load)
|
||||
_dispatch_pending_load()
|
||||
|
||||
|
||||
func _dispatch_pending_load() -> void:
|
||||
var load_path := GameState.pending_load_path
|
||||
if load_path.is_empty():
|
||||
return
|
||||
GameState.pending_load_path = ""
|
||||
var err := SimBridge.send_input({
|
||||
"action": InputMapper.Action.LOAD_GAME,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"path": load_path},
|
||||
})
|
||||
if err != OK:
|
||||
push_error("main.gd: failed to send LOAD_GAME after connection — %s" % error_string(err))
|
||||
if loading_screen:
|
||||
loading_screen.hide_loading(false)
|
||||
|
||||
|
||||
# #501: Detect large position jump indicating a teleport (not normal movement).
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
|
||||
@@ -435,6 +575,7 @@ func _teleport_transition() -> void:
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
_known_recognition_ids.clear() # D-067: reset chimes for new room
|
||||
_known_triangle_ids.clear() # #590: reset activation chimes for new room
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
dialogue_box.hide_dialogue()
|
||||
|
||||
@@ -451,6 +592,33 @@ func _teleport_transition() -> void:
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
|
||||
# #174: Consume examine result — show overlay when server sends character-filtered observation.
|
||||
# Clears after display (single-consume). Dismiss examine when dialogue opens.
|
||||
func _consume_examine_result() -> void:
|
||||
if GameState.current_examine_result == null or not examine_display:
|
||||
return
|
||||
var result: Dictionary = GameState.current_examine_result
|
||||
# Dismiss existing examine result if dialogue is active (focus priority)
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if examine_display.has_method("dismiss"):
|
||||
examine_display.dismiss()
|
||||
else:
|
||||
if examine_display.has_method("show_result"):
|
||||
examine_display.show_result(result)
|
||||
GameState.current_examine_result = null
|
||||
|
||||
|
||||
# #264: Toggle journal panel. Called from input handler when J key pressed.
|
||||
func _toggle_journal() -> void:
|
||||
if not journal_panel:
|
||||
return
|
||||
# Journal and dialogue cannot be open simultaneously (sprint briefing)
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
return
|
||||
if journal_panel.has_method("toggle"):
|
||||
journal_panel.toggle()
|
||||
|
||||
|
||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||
func _screen_flash(color: Color, duration: float) -> void:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class_name Protocol
|
||||
extends Node
|
||||
## MessagePack codec for the Rust↔Godot wire protocol (D-020).
|
||||
##
|
||||
## Encodes/decodes ObserverSnapshot and PlayerInput to match
|
||||
@@ -11,7 +11,8 @@ class_name Protocol
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 13
|
||||
## v20: adds settings_response field to ObserverSnapshot (#627, D-138).
|
||||
const PROTOCOL_VERSION: int = 20
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -206,6 +207,161 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"target_id": int(raw_end.get("target_id", 0)),
|
||||
})
|
||||
|
||||
# v14: poi_list (#151) — discovered POIs for minimap rendering.
|
||||
# Each entry: {poi_id, name, x, y, z, poi_category}. Positions in sim tile coords.
|
||||
var poi_list: Array = []
|
||||
var raw_pois: Variant = raw.get("poi_list")
|
||||
if raw_pois is Array:
|
||||
for raw_poi in raw_pois:
|
||||
if raw_poi is Dictionary and raw_poi.has("poi_id") and raw_poi.has("x") and raw_poi.has("y"):
|
||||
poi_list.append({
|
||||
"poi_id": str(raw_poi["poi_id"]),
|
||||
"name": str(raw_poi.get("name", "")),
|
||||
"x": int(raw_poi["x"]),
|
||||
"y": int(raw_poi["y"]),
|
||||
"z": int(raw_poi.get("z", 0)),
|
||||
"poi_category": str(raw_poi.get("poi_category", raw_poi.get("category", "Location"))),
|
||||
})
|
||||
|
||||
# v14: examine_result (#174, #242) — character-filtered observation text.
|
||||
# {entity_id, text, confidence} or null. Auto-dismisses on client after 4-6 seconds.
|
||||
var examine_result: Variant = null
|
||||
var raw_examine: Variant = raw.get("examine_result")
|
||||
if raw_examine is Dictionary and raw_examine.has("text"):
|
||||
examine_result = {
|
||||
"entity_id": int(raw_examine.get("entity_id", 0)),
|
||||
"text": str(raw_examine["text"]),
|
||||
"confidence": str(raw_examine.get("confidence", "KnowsOf")),
|
||||
}
|
||||
|
||||
# v15: save_result (#554, D-085) — one-shot save/load operation result.
|
||||
# {success: bool, kind: "save"|"load", error: String|null}
|
||||
var save_result: Variant = null
|
||||
var raw_save: Variant = raw.get("save_result")
|
||||
if raw_save is Dictionary:
|
||||
save_result = {
|
||||
"success": bool(raw_save.get("success", false)),
|
||||
"kind": str(raw_save.get("kind", "")),
|
||||
"error": raw_save.get("error"),
|
||||
}
|
||||
|
||||
# v18: debug_response (#580) — debug console command result.
|
||||
# {command: String, text: String, success: bool}
|
||||
var debug_response: Variant = null
|
||||
var raw_debug: Variant = raw.get("debug_response")
|
||||
if raw_debug is Dictionary:
|
||||
debug_response = {
|
||||
"command": str(raw_debug.get("command", "")),
|
||||
"text": str(raw_debug.get("text", "")),
|
||||
"success": bool(raw_debug.get("success", false)),
|
||||
}
|
||||
|
||||
# v19: triangle_crisis_events (#590, D-072/D-089) — one-shot activation events.
|
||||
# Each entry: {triangle_id: int}. Client deduplicates by triangle_id across ticks.
|
||||
# v0.1 intentional omissions: role_assignments, trigger_npc_id, tick are not decoded
|
||||
# here — the client has no use for them in v0.1 (no overlay, no entity targeting).
|
||||
# Add when #593+ requires richer client-side event handling.
|
||||
var triangle_crisis_events: Array = []
|
||||
var raw_tce: Variant = raw.get("triangle_crisis_events")
|
||||
if raw_tce is Array:
|
||||
for raw_ev in raw_tce:
|
||||
if raw_ev is Dictionary and raw_ev.has("triangle_id"):
|
||||
triangle_crisis_events.append({
|
||||
"triangle_id": int(raw_ev["triangle_id"]),
|
||||
})
|
||||
|
||||
# v20: settings_response (#627, D-138) — server ack after ChangeSettings / full settings dump
|
||||
# after RequestAllSettings. kind = "full"|"ack". "full": settings array [{key, value}].
|
||||
# "ack": {success: bool, key: String, error: String|null}.
|
||||
var settings_response: Variant = null
|
||||
var raw_sr: Variant = raw.get("settings_response")
|
||||
if raw_sr is Dictionary:
|
||||
var sr_kind: String = str(raw_sr.get("kind", ""))
|
||||
if sr_kind == "full":
|
||||
var sr_settings: Array = []
|
||||
var raw_sr_settings: Variant = raw_sr.get("settings")
|
||||
if raw_sr_settings is Array:
|
||||
for raw_s in raw_sr_settings:
|
||||
if raw_s is Dictionary and raw_s.has("key"):
|
||||
sr_settings.append({
|
||||
"key": str(raw_s["key"]),
|
||||
"value": raw_s.get("value"),
|
||||
})
|
||||
settings_response = {"kind": "full", "settings": sr_settings}
|
||||
elif sr_kind == "ack":
|
||||
settings_response = {
|
||||
"kind": "ack",
|
||||
"success": bool(raw_sr.get("success", false)),
|
||||
"key": str(raw_sr.get("key", "")),
|
||||
"error": raw_sr.get("error"),
|
||||
}
|
||||
|
||||
# v19: current_ticker (#592) — scrolling news headline when in The Last Shift zone.
|
||||
# {id: String, text: String, category: String} or null when player outside bar zone.
|
||||
var current_ticker: Variant = null
|
||||
var raw_ticker: Variant = raw.get("current_ticker")
|
||||
if raw_ticker is Dictionary and raw_ticker.has("text"):
|
||||
current_ticker = {
|
||||
"id": str(raw_ticker.get("id", "")),
|
||||
"text": str(raw_ticker["text"]),
|
||||
"category": str(raw_ticker.get("category", "")),
|
||||
}
|
||||
|
||||
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
|
||||
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
|
||||
# When server populates this field, client-side accumulation fallback in game_state.gd
|
||||
# can be removed — apply_snapshot() should contain only direct field assignments.
|
||||
var stationary_ticks: Variant = null
|
||||
var raw_st: Variant = raw.get("stationary_ticks")
|
||||
if raw_st != null:
|
||||
stationary_ticks = int(raw_st)
|
||||
|
||||
# TODO(server): Send top-level zone_id string in ObserverSnapshot (D-073, D-020).
|
||||
# Server sends zone_id per VisibleTile but not as a top-level snapshot field.
|
||||
# When server populates this, client-side tile iteration fallback in game_state.gd
|
||||
# can be removed — apply_snapshot() should contain only direct field assignments.
|
||||
var zone_id: Variant = null
|
||||
var raw_zid: Variant = raw.get("zone_id")
|
||||
if raw_zid is String:
|
||||
zone_id = raw_zid
|
||||
|
||||
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
|
||||
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
|
||||
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
|
||||
var player_knowledge: Variant = null
|
||||
var raw_pk: Variant = raw.get("player_knowledge")
|
||||
if raw_pk is Dictionary:
|
||||
var kg_entities: Array = []
|
||||
var raw_kg_entities: Variant = raw_pk.get("entities")
|
||||
if raw_kg_entities is Array:
|
||||
for raw_ke in raw_kg_entities:
|
||||
if raw_ke is Dictionary and raw_ke.has("entity_id"):
|
||||
kg_entities.append({
|
||||
"entity_id": int(raw_ke["entity_id"]),
|
||||
"name": str(raw_ke.get("name", "Unknown")),
|
||||
"confidence": str(raw_ke.get("confidence", "Suspects")),
|
||||
"source": str(raw_ke.get("source", "")),
|
||||
"state": str(raw_ke.get("state", "Active")),
|
||||
"relationship": str(raw_ke.get("relationship", "Unknown")),
|
||||
"last_observed_tick": int(raw_ke.get("last_observed_tick", 0)),
|
||||
})
|
||||
var kg_facts: Array = []
|
||||
var raw_kg_facts: Variant = raw_pk.get("facts")
|
||||
if raw_kg_facts is Array:
|
||||
for raw_kf in raw_kg_facts:
|
||||
if raw_kf is Dictionary and raw_kf.has("fact_id"):
|
||||
kg_facts.append({
|
||||
"fact_id": str(raw_kf["fact_id"]),
|
||||
"confidence": str(raw_kf.get("confidence", "Suspects")),
|
||||
"source": str(raw_kf.get("source", "")),
|
||||
"state": str(raw_kf.get("state", "Active")),
|
||||
"acquired_tick": int(raw_kf.get("acquired_tick", 0)),
|
||||
})
|
||||
player_knowledge = {
|
||||
"entities": kg_entities,
|
||||
"facts": kg_facts,
|
||||
}
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"entities": entities,
|
||||
@@ -223,6 +379,16 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"pending_recognitions": pending_recognitions,
|
||||
"conversation_events": conversation_events,
|
||||
"conversation_ended": conversation_ended,
|
||||
"poi_list": poi_list,
|
||||
"examine_result": examine_result,
|
||||
"player_knowledge": player_knowledge,
|
||||
"save_result": save_result,
|
||||
"debug_response": debug_response,
|
||||
"stationary_ticks": stationary_ticks,
|
||||
"zone_id": zone_id,
|
||||
"triangle_crisis_events": triangle_crisis_events,
|
||||
"current_ticker": current_ticker,
|
||||
"settings_response": settings_response,
|
||||
}
|
||||
|
||||
|
||||
@@ -328,6 +494,34 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
|
||||
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
|
||||
static func encode_startup_message(world_seed: int, character_archetype: String = "detective") -> PackedByteArray:
|
||||
# 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,
|
||||
}
|
||||
var result = Messagepack.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: startup message encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Encode a PlayerInput to MessagePack bytes.
|
||||
## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
|
||||
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
|
||||
@@ -381,6 +575,24 @@ static func _encode_action(action_name: String, action_data: Variant) -> Variant
|
||||
return action_name
|
||||
|
||||
|
||||
## Encode a ChangeSettings action for the AI-Enhanced Dialogue toggle (#646, D-138).
|
||||
## Returns a MessagePack-encoded Vec<PlayerInput> in buffer-entry format
|
||||
## (action_name + action_data), suitable for inspection in tests and for
|
||||
## queuing via SimBridge._outbound_buffer.
|
||||
## The server receives this as a PlayerAction::ChangeSettings variant.
|
||||
static func encode_change_settings(enabled: bool) -> PackedByteArray:
|
||||
var entries: Array = [{
|
||||
"tick": 0,
|
||||
"action_name": "ChangeSettings",
|
||||
"action_data": {"ai_enhanced_dialogue": enabled},
|
||||
}]
|
||||
var result = Messagepack.encode(entries)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_change_settings failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
class_name TestHarness
|
||||
extends RefCounted
|
||||
## Standalone test simulation for client development without a running server.
|
||||
## Generates mock ObserverSnapshots with movement, LOS, dialogue, and NPC
|
||||
## interactions. Extracted from sim_bridge.gd to enforce D-020 information
|
||||
## boundary (no game logic in the production client autoload).
|
||||
|
||||
var tick: int = 0
|
||||
var player_pos: Vector2i = Vector2i(10, 10)
|
||||
var facing: String = "North"
|
||||
var input_queue: Array = []
|
||||
var in_dialogue: bool = false
|
||||
var gauntlet_mode: bool = false
|
||||
var npc_relationship: String = "Unknown"
|
||||
|
||||
|
||||
func reset() -> void:
|
||||
tick = 0
|
||||
player_pos = Vector2i(10, 10)
|
||||
facing = "North"
|
||||
input_queue.clear()
|
||||
in_dialogue = false
|
||||
gauntlet_mode = false
|
||||
npc_relationship = "Unknown"
|
||||
|
||||
|
||||
func process_input(action_name: String) -> void:
|
||||
input_queue.append(action_name)
|
||||
|
||||
|
||||
func process_facing(new_facing: String) -> void:
|
||||
facing = new_facing
|
||||
|
||||
|
||||
# -- Snapshot generation -------------------------------------------------------
|
||||
|
||||
func snapshot() -> Dictionary:
|
||||
tick += 1
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in input_queue:
|
||||
if action_name == "TeleportToHub":
|
||||
player_pos = Vector2i(10, 10)
|
||||
in_dialogue = false
|
||||
continue
|
||||
if action_name == "Interact":
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var dist := absi(player_pos.x - npc_pos.x) + absi(player_pos.y - npc_pos.y)
|
||||
if dist <= 2 and has_los(player_pos, npc_pos):
|
||||
in_dialogue = true
|
||||
continue
|
||||
var delta := action_to_delta(action_name)
|
||||
var new_pos := player_pos + delta
|
||||
if _is_walkable(new_pos):
|
||||
player_pos = new_pos
|
||||
if delta != Vector2i.ZERO:
|
||||
if in_dialogue:
|
||||
in_dialogue = false
|
||||
input_queue.clear()
|
||||
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
|
||||
# Build entities — player always visible
|
||||
var entities: Array = [{
|
||||
"entity_id": 1,
|
||||
"x": float(px),
|
||||
"y": float(py),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Player", "data": null },
|
||||
"visibility": "Forward",
|
||||
}]
|
||||
|
||||
# NPC at (12, 9) — visible if within range and not blocked by wall at (12, 10)
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var npc_dist := absi(px - npc_pos.x) + absi(py - npc_pos.y)
|
||||
if npc_dist <= 4 and has_los(Vector2i(px, py), npc_pos):
|
||||
var sector: String = "Forward" if npc_pos.y <= py else "Peripheral"
|
||||
entities.append({
|
||||
"entity_id": 2,
|
||||
"x": float(npc_pos.x),
|
||||
"y": float(npc_pos.y),
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": npc_relationship,
|
||||
})
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
var nearby: Array = []
|
||||
if npc_dist <= 2 and has_los(Vector2i(px, py), npc_pos):
|
||||
nearby.append({
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
})
|
||||
|
||||
# v5: monologue on first tick (#414)
|
||||
var monologue: Variant = null
|
||||
if tick == 1:
|
||||
monologue = {
|
||||
"id": "test_enter_001",
|
||||
"text": "Sova Transit District. Population twelve thousand and change.",
|
||||
"duration_seconds": 5.0,
|
||||
}
|
||||
|
||||
# v7: mock dialogue (#435, D-061/D-062)
|
||||
var dialogue: Variant = null
|
||||
if in_dialogue:
|
||||
dialogue = {
|
||||
"npc_name": "Kael",
|
||||
"npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options": [
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
||||
],
|
||||
}
|
||||
|
||||
# v7: mock pending_recognitions (#431, D-059/D-060)
|
||||
var pending_recs: Array = []
|
||||
var cycle_pos := tick % 12
|
||||
if cycle_pos < 6:
|
||||
var total_delay := 6
|
||||
var remaining := total_delay - cycle_pos
|
||||
pending_recs.append({
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
if tick >= conv_start and tick < conv_start + conv_total_ticks:
|
||||
var conv_index := (tick - conv_start) / conv_tick_interval
|
||||
var within_tick := (tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
elif tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": tick * 10,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": "Full",
|
||||
},
|
||||
"player_facing": facing,
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": entities,
|
||||
"visible_tiles": _visible_tiles(),
|
||||
"nearby_interactions": nearby,
|
||||
"current_monologue": monologue,
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": gauntlet_mode,
|
||||
"conversation_events": conv_events,
|
||||
"conversation_ended": conv_ended,
|
||||
"save_result": null,
|
||||
}
|
||||
|
||||
|
||||
# -- Map generation ------------------------------------------------------------
|
||||
|
||||
func _tiles() -> Array:
|
||||
var tiles: Array = []
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(room_x, room_x + room_w):
|
||||
for y in range(room_y, room_y + room_h):
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var tile_type: String
|
||||
if is_edge:
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
tile_type = "door"
|
||||
else:
|
||||
tile_type = "wall"
|
||||
else:
|
||||
tile_type = "floor"
|
||||
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
||||
|
||||
var door_x := room_x + room_w / 2
|
||||
for y in range(room_y + room_h, room_y + room_h + 4):
|
||||
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
||||
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
||||
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
||||
|
||||
return tiles
|
||||
|
||||
|
||||
func _visible_tiles() -> Array:
|
||||
var vtiles: Array = []
|
||||
var px := player_pos.x
|
||||
var py := player_pos.y
|
||||
var radius := 5
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(px - radius, px + radius + 1):
|
||||
for y in range(py - radius, py + radius + 1):
|
||||
var dist := absf(x - px) + absf(y - py)
|
||||
if dist <= radius:
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
var sector: String = "Forward" if y <= py else "Peripheral"
|
||||
vtiles.append({"x": x, "y": y, "z": 0, "visibility": sector, "type": _get_tile_type(x, y)})
|
||||
return vtiles
|
||||
|
||||
|
||||
func _get_tile_type(x: int, y: int) -> String:
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
if is_edge:
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
return "door"
|
||||
return "wall"
|
||||
return "floor"
|
||||
|
||||
|
||||
# -- Spatial helpers -----------------------------------------------------------
|
||||
|
||||
const _WALLS: Array = [
|
||||
# Room walls (8x8 room from (7,7) to (14,14))
|
||||
Vector2i(7,7), Vector2i(8,7), Vector2i(9,7), Vector2i(10,7),
|
||||
Vector2i(11,7), Vector2i(12,7), Vector2i(13,7), Vector2i(14,7),
|
||||
Vector2i(7,14), Vector2i(8,14), Vector2i(9,14), Vector2i(10,14),
|
||||
Vector2i(11,14), Vector2i(12,14), Vector2i(13,14), Vector2i(14,14),
|
||||
Vector2i(7,8), Vector2i(7,9), Vector2i(7,10), Vector2i(7,11),
|
||||
Vector2i(7,12), Vector2i(7,13),
|
||||
Vector2i(14,8), Vector2i(14,9), Vector2i(14,10), Vector2i(14,11),
|
||||
Vector2i(14,12), Vector2i(14,13),
|
||||
# Interior wall blocking NPC
|
||||
Vector2i(12, 10),
|
||||
]
|
||||
|
||||
|
||||
func _is_walkable(pos: Vector2i) -> bool:
|
||||
return not _WALLS.has(pos)
|
||||
|
||||
|
||||
func has_los(from: Vector2i, to: Vector2i) -> bool:
|
||||
var dx := absi(to.x - from.x)
|
||||
var dy := absi(to.y - from.y)
|
||||
var sx := 1 if from.x < to.x else -1
|
||||
var sy := 1 if from.y < to.y else -1
|
||||
var err := dx - dy
|
||||
var cx := from.x
|
||||
var cy := from.y
|
||||
while true:
|
||||
if cx == to.x and cy == to.y:
|
||||
return true
|
||||
if Vector2i(cx, cy) != from and not _is_walkable(Vector2i(cx, cy)):
|
||||
return false
|
||||
var e2 := 2 * err
|
||||
if e2 > -dy:
|
||||
err -= dy
|
||||
cx += sx
|
||||
if e2 < dx:
|
||||
err += dx
|
||||
cy += sy
|
||||
return true
|
||||
|
||||
|
||||
static func action_to_delta(action_name: String) -> Vector2i:
|
||||
match action_name:
|
||||
"MoveNorth": return Vector2i(0, -1)
|
||||
"MoveNortheast": return Vector2i(1, -1)
|
||||
"MoveEast": return Vector2i(1, 0)
|
||||
"MoveSoutheast": return Vector2i(1, 1)
|
||||
"MoveSouth": return Vector2i(0, 1)
|
||||
"MoveSouthwest": return Vector2i(-1, 1)
|
||||
"MoveWest": return Vector2i(-1, 0)
|
||||
"MoveNorthwest": return Vector2i(-1, -1)
|
||||
_: return Vector2i.ZERO
|
||||
|
||||
|
||||
static func delta_to_facing(delta: Vector2i) -> String:
|
||||
match delta:
|
||||
Vector2i(0, -1): return "North"
|
||||
Vector2i(1, -1): return "Northeast"
|
||||
Vector2i(1, 0): return "East"
|
||||
Vector2i(1, 1): return "Southeast"
|
||||
Vector2i(0, 1): return "South"
|
||||
Vector2i(-1, 1): return "Southwest"
|
||||
Vector2i(-1, 0): return "West"
|
||||
Vector2i(-1, -1): return "Northwest"
|
||||
_: return "North"
|
||||
@@ -0,0 +1 @@
|
||||
uid://2p33ypi2y2kv
|
||||
@@ -2,7 +2,7 @@ class_name EntityRenderer
|
||||
extends Node2D
|
||||
|
||||
# Entity renderer — manages entity sprites under the Entities node
|
||||
# Creates/updates/removes ColorRect children based on entity data
|
||||
# Creates/updates/removes Sprite2D children based on entity data
|
||||
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
|
||||
#
|
||||
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
|
||||
@@ -11,13 +11,15 @@ extends Node2D
|
||||
#
|
||||
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
|
||||
# color from RelationshipState via the knowledge graph.
|
||||
# #540: Sprites at D-019 angle (-72.5° from horizontal). Textures are neutral greyscale;
|
||||
# self_modulate applies D-033 relationship tinting. modulate.a reserved for D-015 dimming.
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source scaled to 32px runtime)
|
||||
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source at 0.5 scale = 32px runtime)
|
||||
const ENTITY_WIDTH: int = 24
|
||||
const ENTITY_HEIGHT: int = 32
|
||||
const ENTITY_OFFSET_X: float = (TILE_SIZE - ENTITY_WIDTH) / 2.0 # center horizontally
|
||||
const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically for placeholder. Migration: switch to bottom-anchor (offset = TILE_SIZE - ENTITY_HEIGHT) when real sprites land for correct y-sort ordering.
|
||||
const ENTITY_OFFSET_X: float = 0.0 # sprite fills tile width at 0.5 scale
|
||||
const ENTITY_OFFSET_Y: float = TILE_SIZE - ENTITY_HEIGHT # feet-anchored for correct y-sort with D-019 tilt
|
||||
|
||||
# Lerp speed — framerate-independent exponential smoothing.
|
||||
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
||||
@@ -27,7 +29,8 @@ const LERP_SPEED: float = 12.0
|
||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
||||
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
|
||||
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
|
||||
var _entity_tweens: Dictionary = {} # #521: entity_id -> {from: Color, target: Color, elapsed: float}
|
||||
var _entity_facing: Dictionary = {} # #540: entity_id -> String ("north"/"east"/"south"/"west")
|
||||
|
||||
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
||||
const COLOR_FADE_DURATION: float = 0.5
|
||||
@@ -48,7 +51,7 @@ func _process(delta: float) -> void:
|
||||
if not node.position.is_equal_approx(target):
|
||||
node.position = node.position.lerp(target, weight)
|
||||
|
||||
# #521: Advance color transitions (manual lerp, testable without SceneTree)
|
||||
# #521: Advance self_modulate transitions (manual lerp, testable without SceneTree)
|
||||
var finished_ids: Array = []
|
||||
for entity_id in _entity_tweens.keys():
|
||||
if not entity_nodes.has(entity_id):
|
||||
@@ -57,8 +60,9 @@ func _process(delta: float) -> void:
|
||||
var tween_data: Dictionary = _entity_tweens[entity_id]
|
||||
tween_data.elapsed += delta
|
||||
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
|
||||
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
|
||||
node_c.color = tween_data.from.lerp(tween_data.target, t)
|
||||
var node_s: Sprite2D = entity_nodes[entity_id] as Sprite2D
|
||||
if node_s:
|
||||
node_s.self_modulate = tween_data.from.lerp(tween_data.target, t)
|
||||
if t >= 1.0:
|
||||
finished_ids.append(entity_id)
|
||||
for eid in finished_ids:
|
||||
@@ -92,15 +96,26 @@ func update_entities(entities: Array) -> void:
|
||||
for entity_id in ids_to_remove:
|
||||
_remove_entity_node(entity_id)
|
||||
|
||||
# Create a new entity node with D-033 color and optional facing indicator
|
||||
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
var entity_node = ColorRect.new()
|
||||
entity_node.name = "Entity_" + str(entity_id)
|
||||
entity_node.size = Vector2(ENTITY_WIDTH, ENTITY_HEIGHT)
|
||||
entity_node.pivot_offset = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
|
||||
|
||||
# D-033 color by relationship (#521)
|
||||
entity_node.color = _color_for_kind(entity_data)
|
||||
# Create a new entity node with D-033 tint and sprite texture at D-019 angle
|
||||
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
var entity_node := Sprite2D.new()
|
||||
entity_node.name = "Entity_" + str(entity_id)
|
||||
# centered=false: top-left origin aligns with tile grid.
|
||||
# scale=0.5: maps 64px source texture to 32px runtime (D-043, 2x camera = 64px on screen).
|
||||
entity_node.centered = false
|
||||
entity_node.scale = Vector2(0.5, 0.5)
|
||||
|
||||
# Load sprite for current facing direction
|
||||
var direction := _entity_direction(entity_id, entity_data)
|
||||
_entity_facing[entity_id] = direction
|
||||
var tex := _load_sprite_texture(direction)
|
||||
if tex == null:
|
||||
push_error("EntityRenderer: no texture for entity %d direction '%s' — entity will be invisible" % [entity_id, direction])
|
||||
entity_node.texture = tex
|
||||
|
||||
# D-033: self_modulate for relationship tinting; modulate.a is reserved for D-015 dimming.
|
||||
entity_node.self_modulate = _color_for_kind(entity_data)
|
||||
|
||||
add_child(entity_node)
|
||||
entity_nodes[entity_id] = entity_node
|
||||
@@ -121,12 +136,13 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
|
||||
_update_entity_node(entity_id, entity_data)
|
||||
|
||||
# Update an existing entity node (target position, visibility dimming, facing)
|
||||
|
||||
# Update an existing entity node (target position, sprite direction, visibility dimming, facing)
|
||||
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
if not entity_nodes.has(entity_id):
|
||||
return
|
||||
|
||||
var entity_node = entity_nodes[entity_id]
|
||||
var node = entity_nodes[entity_id]
|
||||
|
||||
# Update target position — the lerp in _process() will smoothly move there.
|
||||
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
|
||||
@@ -136,38 +152,42 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
|
||||
)
|
||||
|
||||
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
|
||||
# #540: Update sprite texture when facing direction changes
|
||||
var new_dir := _entity_direction(entity_id, entity_data)
|
||||
if new_dir != _entity_facing.get(entity_id, ""):
|
||||
_entity_facing[entity_id] = new_dir
|
||||
var new_tex := _load_sprite_texture(new_dir)
|
||||
if new_tex != null:
|
||||
(node as Sprite2D).texture = new_tex
|
||||
# null: keep previous texture rather than going invisible mid-game
|
||||
|
||||
# #521: Detect relationship change → fade D-033 self_modulate (0.5s via _process)
|
||||
var new_rel: String = entity_data.get("relationship", "Unknown")
|
||||
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
|
||||
if new_rel != old_rel:
|
||||
if new_rel != _entity_relationships.get(entity_id, "Unknown"):
|
||||
_entity_relationships[entity_id] = new_rel
|
||||
var new_color := _color_for_kind(entity_data)
|
||||
_entity_tweens[entity_id] = {
|
||||
"from": entity_node.color,
|
||||
"target": new_color,
|
||||
"from": (node as Sprite2D).self_modulate,
|
||||
"target": _color_for_kind(entity_data),
|
||||
"elapsed": 0.0,
|
||||
}
|
||||
|
||||
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
|
||||
# are compositionally independent — both can change simultaneously without
|
||||
# interference. If alpha tweening is added later, coordinate with color tween.
|
||||
|
||||
# v2: Peripheral vision dimming (D-015)
|
||||
# null visibility (v1 backward compat) defaults to full alpha
|
||||
# D-015: Peripheral vision dimming via modulate.a.
|
||||
# Independent from self_modulate (D-033 tint) — both can change simultaneously.
|
||||
var visibility: Variant = entity_data.get("visibility")
|
||||
var target_alpha := Constants.PERIPHERAL_ALPHA if visibility == "Peripheral" else 1.0
|
||||
if not is_equal_approx(entity_node.modulate.a, target_alpha):
|
||||
entity_node.modulate.a = target_alpha
|
||||
if not is_equal_approx(node.modulate.a, target_alpha):
|
||||
node.modulate.a = target_alpha
|
||||
|
||||
# D-054: Update facing indicator from client-side mouse angle (not server).
|
||||
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
|
||||
if entity_id == GameState.player_entity_id:
|
||||
var indicator = entity_node.get_node_or_null("FacingIndicator")
|
||||
var indicator = node.get_node_or_null("FacingIndicator")
|
||||
if indicator != null:
|
||||
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
|
||||
# Rotate from North basis: add PI/2 to convert.
|
||||
indicator.rotation = InputMapper.facing_angle + PI / 2.0
|
||||
|
||||
|
||||
# Remove an entity node
|
||||
func _remove_entity_node(entity_id: int) -> void:
|
||||
if not entity_nodes.has(entity_id):
|
||||
@@ -179,13 +199,49 @@ func _remove_entity_node(entity_id: int) -> void:
|
||||
_entity_targets.erase(entity_id)
|
||||
_entity_relationships.erase(entity_id)
|
||||
_entity_tweens.erase(entity_id)
|
||||
_entity_facing.erase(entity_id)
|
||||
|
||||
|
||||
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
|
||||
static func _color_for_kind(entity_data: Dictionary) -> Color:
|
||||
return Constants.color_for_entity_kind(entity_data)
|
||||
|
||||
# Add a facing direction indicator triangle to the player entity
|
||||
func _add_facing_indicator(parent_node: Control) -> void:
|
||||
|
||||
# #540: Map entity to current 4-direction sprite key.
|
||||
# Player uses GameState.player_facing (8-octant → 4-cardinal). NPCs default "south".
|
||||
func _entity_direction(entity_id: int, _entity_data: Dictionary) -> String:
|
||||
if entity_id == GameState.player_entity_id:
|
||||
return _octant_to_direction(GameState.player_facing)
|
||||
# NPCs: no facing field in v1 entity format; south is viewer-facing (D-019 angle)
|
||||
return "south"
|
||||
|
||||
|
||||
# Map 8-direction octant string to nearest 4-direction sprite key.
|
||||
# N/NW → north, NE/E → east, SE/S → south, SW/W → west
|
||||
static func _octant_to_direction(octant: String) -> String:
|
||||
match octant:
|
||||
"North", "Northwest": return "north"
|
||||
"Northeast", "East": return "east"
|
||||
"Southeast", "South": return "south"
|
||||
"Southwest", "West": return "west"
|
||||
_:
|
||||
push_warning("EntityRenderer: unrecognised octant '%s' — defaulting to south" % octant)
|
||||
return "south"
|
||||
|
||||
|
||||
# Load the sprite texture for the given 4-direction key.
|
||||
# Falls back to null with a push_warning if the asset is missing.
|
||||
static func _load_sprite_texture(direction: String) -> Texture2D:
|
||||
var path := "res://assets/sprites/npc_generic_%s_64.png" % direction
|
||||
if ResourceLoader.exists(path):
|
||||
return load(path) as Texture2D
|
||||
push_warning("EntityRenderer: sprite not found: %s" % path)
|
||||
return null
|
||||
|
||||
|
||||
# Add a facing direction indicator triangle to the player entity.
|
||||
# Indicator position is in Sprite2D local space (64px texture before 0.5 scale → center at (32,32)).
|
||||
func _add_facing_indicator(parent_node: Node2D) -> void:
|
||||
var indicator := Polygon2D.new()
|
||||
indicator.name = "FacingIndicator"
|
||||
var s := Constants.FACING_INDICATOR_SIZE
|
||||
@@ -197,6 +253,7 @@ func _add_facing_indicator(parent_node: Control) -> void:
|
||||
Vector2(s * 0.6, -offset + s * 0.4),
|
||||
])
|
||||
indicator.color = Constants.ENTITY_COLOR_PLAYER
|
||||
# Position at center of parent ColorRect — rotation around this point
|
||||
indicator.position = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
|
||||
# Sprite2D local space: 64px texture at scale 0.5 → center of visible sprite at (32,32).
|
||||
# Indicator rotates around this point to track player facing direction.
|
||||
indicator.position = Vector2(32.0, 32.0)
|
||||
parent_node.add_child(indicator)
|
||||
|
||||
@@ -4,6 +4,9 @@ extends Node2D
|
||||
## Reads textures from FogState autoload, positions rect to cover viewport.
|
||||
## Architecture: docs/architecture/fog-shader-spec.md
|
||||
|
||||
signal fog_noise_ready
|
||||
var _noise_ready: bool = false
|
||||
|
||||
var _fog_rect: ColorRect
|
||||
var _shader_mat: ShaderMaterial
|
||||
|
||||
@@ -29,6 +32,7 @@ func _ready() -> void:
|
||||
|
||||
# Create seamless noise texture for fog animation
|
||||
var noise := FastNoiseLite.new()
|
||||
noise.seed = 42
|
||||
noise.noise_type = FastNoiseLite.TYPE_PERLIN
|
||||
noise.frequency = 0.03
|
||||
var noise_tex := NoiseTexture2D.new()
|
||||
@@ -36,10 +40,11 @@ func _ready() -> void:
|
||||
noise_tex.width = 256
|
||||
noise_tex.height = 256
|
||||
noise_tex.seamless = true
|
||||
noise_tex.changed.connect(func(): _noise_ready = true; fog_noise_ready.emit())
|
||||
_shader_mat.set_shader_parameter("noise_tex", noise_tex)
|
||||
_shader_mat.set_shader_parameter("tile_size", TILE_SIZE)
|
||||
|
||||
print("FogShader: Initialized (D-059 5-layer)")
|
||||
print("FogShader: Initialized (D-059 3-state)")
|
||||
|
||||
|
||||
func update_fog() -> void:
|
||||
@@ -68,4 +73,6 @@ func update_fog() -> void:
|
||||
_shader_mat.set_shader_parameter("rect_sz", _fog_rect.size)
|
||||
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
|
||||
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size))
|
||||
_shader_mat.set_shader_parameter("time", Time.get_ticks_msec() / 1000.0)
|
||||
var t: float = FogState.override_time if FogState.override_time >= 0.0 else Time.get_ticks_msec() / 1000.0
|
||||
_shader_mat.set_shader_parameter("time", t)
|
||||
_shader_mat.set_shader_parameter("debug_exploration", FogState.debug_exploration)
|
||||
|
||||
@@ -64,11 +64,17 @@ func _setup_tileset() -> void:
|
||||
tile_set = ts
|
||||
|
||||
# Update tiles from snapshot data
|
||||
# tiles: Array of {x: int, y: int, z: int, type: String}
|
||||
# tiles: Array of {x: int, y: int, z: int, type: String, visibility: String (optional)}
|
||||
# z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.),
|
||||
# NOT the Godot scene z_index (which controls render order within a floor).
|
||||
# This node only renders floor-level 0. Higher floor levels will be handled
|
||||
# by separate TileMapLayer nodes when multi-floor rendering is implemented.
|
||||
#
|
||||
# BoundaryWall tiles (#585): visibility="BoundaryWall" tiles (wall tiles 1 step beyond
|
||||
# LOS boundary) are rendered normally here — they have a "type" field from protocol.gd
|
||||
# so they composite correctly under the fog shader. The fog/exploration exemption is
|
||||
# handled in fog_state.gd (VIS_FORWARD without EXP_VISIBLE) and game_state.gd
|
||||
# (boundary_positions not visible_positions). No special handling needed in this method.
|
||||
func update_tiles(tiles: Array) -> void:
|
||||
if not _initialized:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
class_name SnapshotEventRouter
|
||||
## Routes snapshot fields to registered handlers (D-020, #559).
|
||||
##
|
||||
## Decouples main.gd from knowing which child node handles which snapshot field.
|
||||
## Handlers are registered in main.gd._ready(); dispatch() is called each snapshot tick.
|
||||
##
|
||||
## Two handler types:
|
||||
## - Keyed: called only when the snapshot contains a specific field.
|
||||
## - Always: called every dispatch (every snapshot tick), regardless of fields present.
|
||||
##
|
||||
## All handlers are zero-argument callables — they read from GameState directly.
|
||||
## This preserves GameState as the single source of truth post-apply_snapshot().
|
||||
|
||||
## Keyed handlers: field_name → Array[Callable]
|
||||
## Array per field allows multiple handlers on the same key (e.g., two consumers of same data).
|
||||
var _keyed: Dictionary = {} # String → Array[Callable]
|
||||
|
||||
## Always handlers: called every dispatch regardless of snapshot content.
|
||||
var _always: Array[Callable] = []
|
||||
|
||||
|
||||
## Register a handler for a specific snapshot field key.
|
||||
## Handler is called (with no arguments) when snapshot.has(field) is true.
|
||||
## Multiple handlers per field are supported — they run in registration order.
|
||||
func register(field: String, handler: Callable) -> void:
|
||||
if not _keyed.has(field):
|
||||
_keyed[field] = []
|
||||
_keyed[field].append(handler)
|
||||
|
||||
|
||||
## Register a handler that runs every dispatch tick (not keyed to a field).
|
||||
## Use for child nodes that update from GameState on every snapshot, e.g. update_from_state().
|
||||
func register_always(handler: Callable) -> void:
|
||||
_always.append(handler)
|
||||
|
||||
|
||||
## Dispatch a snapshot: call always handlers first, then keyed handlers for present fields.
|
||||
## Handlers read from GameState.* directly — apply_snapshot() must be called before dispatch().
|
||||
func dispatch(snapshot: Dictionary) -> void:
|
||||
for handler in _always:
|
||||
handler.call()
|
||||
for field in _keyed:
|
||||
if snapshot.has(field):
|
||||
for handler in _keyed[field]:
|
||||
handler.call()
|
||||
@@ -0,0 +1 @@
|
||||
uid://drppri4b46v80
|
||||
@@ -1,115 +0,0 @@
|
||||
extends Control
|
||||
# #511: F3 debug overlay — real-time game state display for dev use.
|
||||
|
||||
const HEADER_COLOR := Color("#e8c547")
|
||||
const LABEL_COLOR := Color("#8890a0")
|
||||
const VALUE_COLOR := Color("#c8d0e0")
|
||||
const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
|
||||
const FONT_SIZE := 12
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(10, 8)
|
||||
const COL_GAP := 16 # gap between left and right columns
|
||||
|
||||
var _cached_font: Font = null
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
_cached_font = ThemeDB.fallback_font
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("debug_overlay"):
|
||||
visible = not visible
|
||||
if visible:
|
||||
queue_redraw()
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not visible:
|
||||
return
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
if not visible:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
|
||||
# Build lines as [label, value, label, value] pairs (two columns)
|
||||
var left_lines: Array = []
|
||||
var right_lines: Array = []
|
||||
|
||||
left_lines.append(["tick", str(GameState.current_tick)])
|
||||
right_lines.append(["fps", str(Engine.get_frames_per_second())])
|
||||
|
||||
var pos := GameState.player_position
|
||||
left_lines.append(["pos", "(%d, %d)" % [int(pos.x), int(pos.y)]])
|
||||
right_lines.append(["facing", GameState.player_facing])
|
||||
|
||||
left_lines.append(["stance", GameState.player_stance])
|
||||
right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"])
|
||||
|
||||
left_lines.append(["entities", str(GameState.visible_entities.size())])
|
||||
right_lines.append(["tiles", str(GameState.visible_tiles.size())])
|
||||
|
||||
left_lines.append(["interactions", str(GameState.nearby_interactions.size())])
|
||||
right_lines.append(["recognitions", str(GameState.pending_recognitions.size())])
|
||||
|
||||
var mono_status := "active" if GameState.current_monologue != null else "idle"
|
||||
var dlg_status := "active" if GameState.dialogue_active else "idle"
|
||||
left_lines.append(["monologue", mono_status])
|
||||
right_lines.append(["dialogue", dlg_status])
|
||||
|
||||
left_lines.append(["stationary", str(GameState.stationary_ticks)])
|
||||
right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"])
|
||||
|
||||
var gt := GameState.game_time
|
||||
var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-"
|
||||
var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-"
|
||||
left_lines.append(["time", time_str])
|
||||
right_lines.append(["tick_rate", rate_str])
|
||||
|
||||
var mode_str := "test" if SimBridge.test_mode else "live"
|
||||
var gauntlet_str := "ON" if GameState.gauntlet_mode else "OFF"
|
||||
left_lines.append(["mode", mode_str])
|
||||
right_lines.append(["gauntlet", gauntlet_str])
|
||||
|
||||
# Measure column widths
|
||||
var left_label_w: float = 0.0
|
||||
var left_value_w: float = 0.0
|
||||
var right_label_w: float = 0.0
|
||||
var right_value_w: float = 0.0
|
||||
|
||||
for line in left_lines:
|
||||
left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
for line in right_lines:
|
||||
right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
|
||||
var header_text := "F3 DEBUG"
|
||||
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
# Data lines (two columns)
|
||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||
for i in range(line_count):
|
||||
if i < left_lines.size():
|
||||
var lbl: String = left_lines[i][0] + ": "
|
||||
var val: String = left_lines[i][1]
|
||||
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
if i < right_lines.size():
|
||||
var lbl: String = right_lines[i][0] + ": "
|
||||
var val: String = right_lines[i][1]
|
||||
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
@@ -0,0 +1,163 @@
|
||||
class_name YamlParser
|
||||
## Shared YAML parser — common subset used by ui_strings.gd and checklist_evaluator.gd.
|
||||
##
|
||||
## Handles: nested sections (maps), arrays of dict items (- key: val), typed values.
|
||||
## Returns a hierarchical Dictionary. Use flatten() to convert to dotted-key format
|
||||
## (as UIStrings._parse_yaml() requires).
|
||||
##
|
||||
## Limitations: single-line values only; no YAML anchors/aliases; no flow syntax.
|
||||
## String values: quotes stripped. Booleans, ints, and floats are type-inferred.
|
||||
##
|
||||
## Spec ref: #560 (Sprint 20 — unify duplicate YAML parsers), D-030 (testability).
|
||||
|
||||
|
||||
## Parse YAML text into a hierarchical Dictionary.
|
||||
## Nested sections become nested dicts. Array items (- key: val) become Arrays.
|
||||
## Values are type-inferred: bool, int, float, or String.
|
||||
static func parse(text: String) -> Dictionary:
|
||||
var root: Dictionary = {}
|
||||
# Stack: [{indent: int, key: String}] — path of open section headers
|
||||
var stack: Array = []
|
||||
# Array state
|
||||
var current_array: Variant = null # Array being built, or null
|
||||
var current_item: Variant = null # Dict being built for current array item, or null
|
||||
var array_parent_indent: int = -1 # indent of the "key:" line that owns the array
|
||||
|
||||
for raw_line in text.split("\n"):
|
||||
var stripped := raw_line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||
continue
|
||||
var indent: int = raw_line.length() - raw_line.lstrip(" ").length()
|
||||
var content: String = stripped.strip_edges()
|
||||
|
||||
# --- Array item (- key: value) ---
|
||||
if content.begins_with("- "):
|
||||
# First item: convert parent section's {} placeholder to []
|
||||
if current_array == null and stack.size() > 0:
|
||||
var parent := _node_at(root, stack, true)
|
||||
var arr_key: String = stack.back()["key"]
|
||||
var new_arr: Array = []
|
||||
parent[arr_key] = new_arr
|
||||
current_array = new_arr
|
||||
array_parent_indent = stack.back()["indent"]
|
||||
# Flush previous item and start a new one
|
||||
if current_item != null:
|
||||
current_array.append(current_item)
|
||||
current_item = {}
|
||||
var rest: String = content.substr(2).strip_edges()
|
||||
var colon: int = rest.find(":")
|
||||
if colon >= 0:
|
||||
var k: String = rest.substr(0, colon).strip_edges()
|
||||
var v: String = rest.substr(colon + 1).strip_edges()
|
||||
current_item[k] = _parse_value(v)
|
||||
continue
|
||||
|
||||
# --- Continuation line within current array item ---
|
||||
if current_array != null and indent > array_parent_indent:
|
||||
var colon: int = content.find(":")
|
||||
if colon >= 0 and current_item != null:
|
||||
var k: String = content.substr(0, colon).strip_edges()
|
||||
var v: String = content.substr(colon + 1).strip_edges()
|
||||
current_item[k] = _parse_value(v)
|
||||
continue
|
||||
|
||||
# --- End of array (indent has returned to array level or above) ---
|
||||
if current_array != null:
|
||||
if current_item != null:
|
||||
current_array.append(current_item)
|
||||
current_item = null
|
||||
current_array = null
|
||||
array_parent_indent = -1
|
||||
if stack.size() > 0:
|
||||
stack.pop_back() # pop the array-owning key
|
||||
|
||||
# --- Regular key: value or section header ---
|
||||
var colon: int = content.find(":")
|
||||
if colon < 0:
|
||||
continue
|
||||
var key: String = content.substr(0, colon).strip_edges()
|
||||
var val_str: String = content.substr(colon + 1).strip_edges()
|
||||
|
||||
# Pop sections at the same or deeper indent (we're back at a shallower level)
|
||||
while stack.size() > 0 and stack.back()["indent"] >= indent:
|
||||
stack.pop_back()
|
||||
|
||||
var node: Dictionary = _node_at(root, stack, false)
|
||||
|
||||
if val_str.is_empty() or val_str.begins_with("#"):
|
||||
# Section header — create nested dict (may become Array if - items follow)
|
||||
node[key] = {}
|
||||
stack.push_back({"indent": indent, "key": key})
|
||||
else:
|
||||
node[key] = _parse_value(val_str)
|
||||
|
||||
# Flush the last array item if the file ended inside an array
|
||||
if current_array != null and current_item != null:
|
||||
current_array.append(current_item)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
## Convenience: parse text and flatten to dotted-key format in one call.
|
||||
## Used by UIStrings._parse_yaml() — equivalent to flatten(parse(text)).
|
||||
static func parse_flat(text: String) -> Dictionary:
|
||||
return flatten(parse(text))
|
||||
|
||||
|
||||
## Flatten a hierarchical dict to dotted-key format (for UIStrings compatibility).
|
||||
## {"a": {"b": "v"}} → {"a.b": "v"}
|
||||
## Arrays are skipped — dotted-key format does not represent them.
|
||||
## All values are converted to String (UIStrings stores display text, not typed data).
|
||||
static func flatten(d: Dictionary, prefix: String = "") -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for k in d:
|
||||
var full_key: String = (prefix + "." if not prefix.is_empty() else "") + str(k)
|
||||
var v = d[k]
|
||||
if v is Dictionary:
|
||||
result.merge(flatten(v, full_key))
|
||||
elif not v is Array:
|
||||
result[full_key] = str(v)
|
||||
return result
|
||||
|
||||
|
||||
## Parse a single YAML value string into a typed GDScript value.
|
||||
## Strips inline comments, handles quoted strings, infers bool/int/float/String.
|
||||
static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
# Strip inline comment outside quotes
|
||||
if not val.begins_with("\""):
|
||||
var comment_pos: int = val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
# Quoted string — extract content between quotes
|
||||
if val.begins_with("\""):
|
||||
var end_quote: int = val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
# Boolean
|
||||
if val == "true": return true
|
||||
if val == "false": return false
|
||||
# Float (must have decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
# Integer
|
||||
if val.is_valid_int():
|
||||
return val.to_int()
|
||||
# Plain string
|
||||
return val
|
||||
|
||||
|
||||
## Navigate root following the stack key path.
|
||||
## parent=true: navigate one level less (returns the parent node, not the leaf).
|
||||
static func _node_at(root: Dictionary, stack: Array, parent: bool) -> Dictionary:
|
||||
var node: Dictionary = root
|
||||
var depth: int = stack.size() - (1 if parent else 0)
|
||||
for i in range(depth):
|
||||
var k: String = stack[i]["key"]
|
||||
if node.has(k) and node[k] is Dictionary:
|
||||
node = node[k]
|
||||
else:
|
||||
break
|
||||
return node
|
||||
@@ -0,0 +1 @@
|
||||
uid://cix55xks85vl8
|
||||
@@ -1,15 +1,21 @@
|
||||
shader_type canvas_item;
|
||||
|
||||
// D-059: 5-layer fog shader. Composites over world content (layers 0-4).
|
||||
// Layer 1: Clear (vision cone) — transparent, soft gradient edge
|
||||
// Layer 2: Light fog (peripheral) — desaturated + dim + animated noise, 8-10s cycle
|
||||
// Layer 3: Deep fog (explored) — near-monochrome + zone tint + breathing, 15-20s cycle
|
||||
// Layer 4: Unexplored + maps — wireframe (Sprint 6: deferred, treated as Layer 5)
|
||||
// Layer 5: Unexplored, no maps — solid near-black #12141a
|
||||
// D-059/D-015: 3-state fog shader (simplified from 5-layer by #569).
|
||||
// State 1: Clear (forward cone) — transparent, soft gradient edge (6-8 tile radius)
|
||||
// State 2: Explored (out of cone) — light fog overlay, alpha 0.25-0.35, zone temperature tint,
|
||||
// 8-10s Perlin breathe. Art and information preserved, just "not fresh" (D-015).
|
||||
// State 3: Unexplored — solid near-black #12141a
|
||||
// D-033: Entity colors are NOT affected — they render above the fog overlay (z-layer 5).
|
||||
// D-046: Zone temperature tint from zone_tint_tex — warm=bar, cool=hub, neutral=corridor.
|
||||
// D-077: zone_tint_tex populated per-tile from server zone_id via fog_state.gd.
|
||||
//
|
||||
// Texture pipeline (fog_state.gd): binary 0/255 at 1× tile resolution → CPU Gaussian blur
|
||||
// (sigma 2.0, 6-8 tile gradient) → Image.resize 4× bilinear upscale → RGBA8 convert.
|
||||
// Textures arrive here with smooth sub-tile gradients — no GPU-side blur needed.
|
||||
|
||||
uniform sampler2D visibility_tex : filter_linear, repeat_disable;
|
||||
uniform sampler2D exploration_tex : filter_linear, repeat_disable;
|
||||
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable;
|
||||
uniform sampler2D visibility_tex : filter_linear, repeat_disable; // RGBA8, 4× tile resolution
|
||||
uniform sampler2D exploration_tex : filter_linear, repeat_disable; // RGBA8, 4× tile resolution
|
||||
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable; // D-073: hard zone boundaries
|
||||
uniform sampler2D noise_tex : filter_linear, repeat_enable;
|
||||
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
|
||||
uniform vec2 rect_sz; // World-space size of the ColorRect (pixels)
|
||||
@@ -17,57 +23,57 @@ uniform vec2 map_offset; // map_bounds.position (tiles)
|
||||
uniform vec2 map_size; // map_bounds.size (tiles)
|
||||
uniform float tile_size; // Pixels per sim tile
|
||||
uniform float time; // Seconds since start
|
||||
uniform bool debug_exploration = false; // When true, render raw exploration texture
|
||||
|
||||
// D-059 fog layer colors
|
||||
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
|
||||
const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05);
|
||||
|
||||
// D-059 thresholds (after bilinear filtering)
|
||||
// Forward tiles = 1.0, Peripheral = 0.706 (180/255), not-visible = 0.0
|
||||
const float CLEAR_THRESHOLD = 0.85; // Above this: fully clear
|
||||
const float PERIPHERAL_LOW = 0.55; // Below this: transition to deep/unexplored
|
||||
|
||||
void fragment() {
|
||||
// Map UV (0-1 across ColorRect) to world pixels, then to tile coordinates
|
||||
vec2 world_px = rect_pos + UV * rect_sz;
|
||||
vec2 tile = world_px / tile_size;
|
||||
|
||||
// Map tile coordinate to texture UV
|
||||
vec2 tex_uv = (tile - map_offset) / map_size;
|
||||
|
||||
// Outside known map → unexplored
|
||||
// Outside known map -> unexplored
|
||||
// Note: no early return — fragment() in OpenGL3 compat doesn't support return.
|
||||
if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) {
|
||||
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
|
||||
|
||||
// Debug mode: render RAW exploration texture with filter_linear.
|
||||
// If hardware bilinear works, should show smooth sub-tile gradients.
|
||||
} else if (debug_exploration) {
|
||||
float explored_raw = texture(exploration_tex, tex_uv).r;
|
||||
COLOR = vec4(vec3(explored_raw), 0.9);
|
||||
|
||||
} else {
|
||||
float vis = texture(visibility_tex, tex_uv).r;
|
||||
float explored = texture(exploration_tex, tex_uv).r;
|
||||
|
||||
if (vis > PERIPHERAL_LOW) {
|
||||
// In or near vision cone
|
||||
if (vis > CLEAR_THRESHOLD) {
|
||||
// Layer 1: Clear — soft edge gradient
|
||||
float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis);
|
||||
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge);
|
||||
} else {
|
||||
// Layer 2: Light fog (peripheral + forward edge)
|
||||
// D-059: animated Perlin noise, 8-10s cycle
|
||||
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
|
||||
float coverage = smoothstep(PERIPHERAL_LOW, CLEAR_THRESHOLD, vis);
|
||||
// Blend from heavy fog (alpha ~0.55) to lighter fog near clear edge
|
||||
float alpha = mix(0.55, 0.25, coverage) + noise_val * 0.1;
|
||||
COLOR = vec4(DARK_OVERLAY, alpha);
|
||||
}
|
||||
} else if (explored > 0.3) {
|
||||
// Layer 3: Deep fog (previously explored, no longer in LOS)
|
||||
// D-059: near-monochrome, ~10% zone temperature tint, 15-20s breathing cycle
|
||||
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
|
||||
float noise_val = texture(noise_tex, tile * 0.015 + vec2(time * 0.045, time * 0.03)).r;
|
||||
vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1);
|
||||
float alpha = mix(0.78, 0.90, noise_val); // Fog breathes
|
||||
COLOR = vec4(tint_color, alpha);
|
||||
} else {
|
||||
// Layer 5: Unexplored, no maps — information zero
|
||||
if (explored < 0.01 && vis < 0.01) {
|
||||
// Unexplored: solid near-black — information zero
|
||||
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
|
||||
} else {
|
||||
// Fog noise — 8-10s breathe cycle, ±0.05 symmetric around baseline
|
||||
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
|
||||
float fog_alpha = 0.30 + (noise_val * 2.0 - 1.0) * 0.05; // 0.25-0.35
|
||||
|
||||
// Zone temperature tint (D-046/D-077): subtle warm/cool/neutral per zone
|
||||
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
|
||||
|
||||
// Clarity ramp: transparent inside cone, light fog at edges and beyond
|
||||
float clarity = smoothstep(0.0, 0.85, vis);
|
||||
float alpha = mix(fog_alpha, 0.0, clarity);
|
||||
vec3 color = mix(zone_tint, vec3(0.0), clarity);
|
||||
|
||||
// Soft edge between explored and unexplored.
|
||||
// The exploration texture is binary (explored-or-not) blurred over ~12 tiles.
|
||||
// At the physical tile boundary, explored ≈ 0.5. Squaring the fade keeps
|
||||
// fog nearly opaque there (97%), hiding tile-aligned content edges.
|
||||
// Content appears gradually 4-6 tiles inside the explored area.
|
||||
float exp_fade = smoothstep(0.3, 1.0, explored);
|
||||
exp_fade *= exp_fade; // Steeper curve: fog stays opaque near content edge
|
||||
alpha = mix(1.0, alpha, exp_fade);
|
||||
color = mix(UNEXPLORED_COLOR, color, exp_fade);
|
||||
|
||||
COLOR = vec4(color, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
うtickヘ,ヲaction�DialogueResponseげtarget_entity_id*ォresponse_idーkael-davan_d_001
|
||||
@@ -0,0 +1 @@
|
||||
う
|
||||
@@ -0,0 +1 @@
|
||||
うtickヲaction→Interactげtarget_entity_idc、verb、Talk
|
||||
@@ -0,0 +1 @@
|
||||
うtickヲactionゥMoveNorth
|
||||