Compare commits
@@ -17,7 +17,6 @@ docs/
|
||||
workshops/ # Workshop briefs and outputs
|
||||
db/
|
||||
schema.sql # Database schema
|
||||
connectors/ # Symlink → tooling/db/ (backwards compat, remove after Sprint 22)
|
||||
tooling/
|
||||
db/ # Connector scripts for SQLite, Qdrant, and audio
|
||||
config.json # Endpoint configuration
|
||||
|
||||
@@ -42,23 +42,6 @@
|
||||
"Bash(tooling/db/audio-post *)",
|
||||
"Bash(tooling/db/audio-batch *)",
|
||||
|
||||
"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(db/connectors/decision *)",
|
||||
|
||||
"Bash(db/connectors/audio-generate *)",
|
||||
"Bash(db/connectors/audio-health)",
|
||||
"Bash(db/connectors/audio-post *)",
|
||||
"Bash(db/connectors/audio-batch *)",
|
||||
|
||||
"Bash(make *)",
|
||||
"Bash(make)",
|
||||
|
||||
|
||||
@@ -6,8 +6,43 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
- Moved connector scripts from db/connectors/ to tooling/db/ (#274) — symlink at old path for backwards compatibility
|
||||
- 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
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
fixtures-client golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
perf-baseline debug-schedule \
|
||||
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark
|
||||
test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark \
|
||||
screenshot visual-movie test-visual visual-update
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -53,6 +54,11 @@ 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)"
|
||||
@@ -318,6 +324,20 @@ 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
|
||||
|
||||
content-ron:
|
||||
cd tooling/content-converter && cargo build --release
|
||||
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,84 @@ 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
|
||||
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
|
||||
visibility_texture.update(_vis_image)
|
||||
|
||||
@@ -106,24 +183,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)
|
||||
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ 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
|
||||
|
||||
@@ -31,12 +31,27 @@ func new_game() -> String:
|
||||
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 from the save directory for D-010 deterministic replay.
|
||||
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)
|
||||
|
||||
|
||||
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||
@@ -111,6 +126,26 @@ func _cleanup_quit_dialog() -> void:
|
||||
_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
|
||||
|
||||
|
||||
func _find_newest_save(dir_path: String) -> String:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://b357ok64jc8vp
|
||||
@@ -231,6 +231,26 @@ func _process(delta: float) -> void:
|
||||
_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)
|
||||
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)
|
||||
return
|
||||
|
||||
@@ -11,7 +11,7 @@ class_name Protocol
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 16
|
||||
const PROTOCOL_VERSION: int = 17
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -427,6 +427,18 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## Server reads this to initialize SimRng with the world seed (D-010, D-029).
|
||||
static func encode_startup_message(world_seed: int) -> PackedByteArray:
|
||||
var msg := {"world_seed": world_seed}
|
||||
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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://2p33ypi2y2kv
|
||||
@@ -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
|
||||
|
||||
@@ -36,10 +39,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 +72,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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://drppri4b46v80
|
||||
@@ -0,0 +1 @@
|
||||
uid://cix55xks85vl8
|
||||
@@ -1,15 +1,17 @@
|
||||
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 Gaussian gradient edge (3-4 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.
|
||||
|
||||
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 zone_tint_tex : filter_nearest, repeat_disable; // nearest: zones have hard boundaries (D-073)
|
||||
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 +19,99 @@ 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
|
||||
// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0).
|
||||
// Spreads the cone boundary into a 3-4 tile radius gradient — no hard tile-stepped edges.
|
||||
float sample_visibility(vec2 uv) {
|
||||
vec2 t = 2.0 / map_size;
|
||||
float sum = 0.0;
|
||||
float weight = 0.0;
|
||||
for (float dy = -3.0; dy <= 3.0; dy += 1.0) {
|
||||
for (float dx = -3.0; dx <= 3.0; dx += 1.0) {
|
||||
float w = exp(-(dx * dx + dy * dy) / 8.0);
|
||||
vec2 sample_uv = clamp(uv + vec2(dx, dy) * t, vec2(0.0), vec2(1.0));
|
||||
sum += texture(visibility_tex, sample_uv).r * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
return sum / weight;
|
||||
}
|
||||
|
||||
// Soft gradient on exploration boundary (5x5, sigma 1.5).
|
||||
// Prevents hard tile-stepped staircase at explored/unexplored edge.
|
||||
float sample_exploration(vec2 uv) {
|
||||
vec2 t = 1.0 / map_size;
|
||||
float sum = 0.0;
|
||||
float weight = 0.0;
|
||||
for (float dy = -2.0; dy <= 2.0; dy += 1.0) {
|
||||
for (float dx = -2.0; dx <= 2.0; dx += 1.0) {
|
||||
float w = exp(-(dx * dx + dy * dy) / 4.5);
|
||||
vec2 sample_uv = clamp(uv + vec2(dx, dy) * t, vec2(0.0), vec2(1.0));
|
||||
sum += texture(exploration_tex, sample_uv).r * w;
|
||||
weight += w;
|
||||
}
|
||||
}
|
||||
return sum / weight;
|
||||
}
|
||||
|
||||
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);
|
||||
} 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);
|
||||
// Debug mode: render raw exploration texture (bypass fog rendering).
|
||||
// Green = EXP_VISIBLE (255), blue = EXP_EXPLORED (128), red = EXP_UNEXPLORED (0).
|
||||
} else if (debug_exploration) {
|
||||
float explored_dbg = texture(exploration_tex, tex_uv).r;
|
||||
if (explored_dbg > 0.9) {
|
||||
COLOR = vec4(0.0, explored_dbg, 0.0, 0.8); // Green: currently visible
|
||||
} else if (explored_dbg > 0.1) {
|
||||
COLOR = vec4(0.0, 0.0, explored_dbg * 2.0, 0.8); // Blue: explored
|
||||
} else {
|
||||
// Layer 5: Unexplored, no maps — information zero
|
||||
COLOR = vec4(0.5, 0.0, 0.0, 0.8); // Red: unexplored
|
||||
}
|
||||
|
||||
} else {
|
||||
float vis_raw = texture(visibility_tex, tex_uv).r;
|
||||
float vis = sample_visibility(tex_uv);
|
||||
float explored_raw = texture(exploration_tex, tex_uv).r;
|
||||
float explored = sample_exploration(tex_uv);
|
||||
|
||||
// Prevent gradient bleed into never-explored tiles (use raw, unblurred value)
|
||||
if (explored_raw < 0.01 && vis_raw < 0.01) {
|
||||
vis = 0.0;
|
||||
}
|
||||
|
||||
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 (blurred to avoid staircase)
|
||||
float exp_fade = smoothstep(0.0, 0.3, explored);
|
||||
alpha = mix(1.0, alpha, exp_fade);
|
||||
color = mix(UNEXPLORED_COLOR, color, exp_fade);
|
||||
|
||||
COLOR = vec4(color, alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://qlxaaip3fqic"
|
||||
path="res://.godot/imported/cursor_menu.png-03cbe8a063e08efcd4283ce76f668ea1.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/cursor_menu.png"
|
||||
dest_files=["res://.godot/imported/cursor_menu.png-03cbe8a063e08efcd4283ce76f668ea1.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: 50 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://oqbx8gwq1xta"
|
||||
path="res://.godot/imported/dialogue_open.png-2e5a2dd99abe2817e4bb7ede8f83b0bf.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/dialogue_open.png"
|
||||
dest_files=["res://.godot/imported/dialogue_open.png-2e5a2dd99abe2817e4bb7ede8f83b0bf.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: 50 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b2hctv70ar7al"
|
||||
path="res://.godot/imported/dialogue_with_monologue.png-5193b3824e2195b1c7ca00bc005f9026.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/dialogue_with_monologue.png"
|
||||
dest_files=["res://.godot/imported/dialogue_with_monologue.png-5193b3824e2195b1c7ca00bc005f9026.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: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bw4kxsno66sv3"
|
||||
path="res://.godot/imported/fog_3state.png-b0193a736de6756c27053a3db24f9a12.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_3state.png"
|
||||
dest_files=["res://.godot/imported/fog_3state.png-b0193a736de6756c27053a3db24f9a12.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: 46 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://7ol3rcrpienc"
|
||||
path="res://.godot/imported/fog_boundary.png-7da06d227319f9c43ef7017ee082732a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_boundary.png"
|
||||
dest_files=["res://.godot/imported/fog_boundary.png-7da06d227319f9c43ef7017ee082732a.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: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://fpncie7mnyay"
|
||||
path="res://.godot/imported/fog_boundary_replay.png-3e1acf3ef35450d1df230400b3003250.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_boundary_replay.png"
|
||||
dest_files=["res://.godot/imported/fog_boundary_replay.png-3e1acf3ef35450d1df230400b3003250.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: 100 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://71e1yuyjdxn7"
|
||||
path="res://.godot/imported/fog_debug.png-7d563b17499f08fbb82ec57bcdcc0e68.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_debug.png"
|
||||
dest_files=["res://.godot/imported/fog_debug.png-7d563b17499f08fbb82ec57bcdcc0e68.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: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://2owpogcjnewr"
|
||||
path="res://.godot/imported/fog_diagonal.png-06416ee4adae921b884280dc73761ed3.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_diagonal.png"
|
||||
dest_files=["res://.godot/imported/fog_diagonal.png-06416ee4adae921b884280dc73761ed3.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: 34 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dufvsap028rfw"
|
||||
path="res://.godot/imported/fog_live_hub.png-a2756ff6cb45af238cc0a32ba70a705a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_live_hub.png"
|
||||
dest_files=["res://.godot/imported/fog_live_hub.png-a2756ff6cb45af238cc0a32ba70a705a.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: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dmqmnfrjrk1u"
|
||||
path="res://.godot/imported/fog_live_replay.png-4604646d390fbfac6491f513f667c5c6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_live_replay.png"
|
||||
dest_files=["res://.godot/imported/fog_live_replay.png-4604646d390fbfac6491f513f667c5c6.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: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dgdt2rmql6hbb"
|
||||
path="res://.godot/imported/fog_theater_replay.png-ef10fe62d9da045193422127e48853ba.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_theater_replay.png"
|
||||
dest_files=["res://.godot/imported/fog_theater_replay.png-ef10fe62d9da045193422127e48853ba.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: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b8weq3wp6tybx"
|
||||
path="res://.godot/imported/fog_zone_tint.png-5a5cd0f27eaf991e1b39709de687dcf4.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/fog_zone_tint.png"
|
||||
dest_files=["res://.godot/imported/fog_zone_tint.png-5a5cd0f27eaf991e1b39709de687dcf4.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: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c5r4tk4yafj2r"
|
||||
path="res://.godot/imported/hud_default.png-d3286d0b35f8bfaeab8436d9645c0c0d.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/hud_default.png"
|
||||
dest_files=["res://.godot/imported/hud_default.png-d3286d0b35f8bfaeab8436d9645c0c0d.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: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://0pym2n4nq12a"
|
||||
path="res://.godot/imported/minimap_stance.png-abbfd0259479cd17651bee48cdc7f7b8.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/minimap_stance.png"
|
||||
dest_files=["res://.godot/imported/minimap_stance.png-abbfd0259479cd17651bee48cdc7f7b8.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: 42 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cc5nmdpumq6t"
|
||||
path="res://.godot/imported/npc_in_fog.png-e8a24e029e5c640a7daee7d3f2838bdc.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tests/golden/visual/npc_in_fog.png"
|
||||
dest_files=["res://.godot/imported/npc_in_fog.png-e8a24e029e5c640a7daee7d3f2838bdc.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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ypniv2crdp8c
|
||||
@@ -250,7 +250,7 @@ func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
|
||||
|
||||
# -- GauntletHUD Feature Tests (#496) ----------------------------------------
|
||||
# Timer lifecycle, personal bests, room change, visibility.
|
||||
# Spec: sprint-9/client.md #496. Ticket: db/connectors/ticket show 496.
|
||||
# Spec: sprint-9/client.md #496. Ticket: tooling/db/ticket show 496.
|
||||
|
||||
func test_format_time_zero() -> void:
|
||||
# Static utility: 0 seconds → "00:00"
|
||||
@@ -476,7 +476,7 @@ func test_gauntlet_snapshot_roundtrip_via_apply() -> void:
|
||||
|
||||
# -- BugReportDialog Feature Tests (#495) ------------------------------------
|
||||
# Pause/unpause lifecycle, wire guard, text render, state machine.
|
||||
# Spec: sprint-9/client.md #495. Ticket: db/connectors/ticket show 495.
|
||||
# Spec: sprint-9/client.md #495. Ticket: tooling/db/ticket show 495.
|
||||
|
||||
func test_bug_report_sends_pause_on_open() -> void:
|
||||
# start_capture() must send Pause to the server.
|
||||
|
||||
@@ -55,7 +55,9 @@ func test_fog_visibility_forward_tile() -> void:
|
||||
|
||||
|
||||
func test_fog_visibility_peripheral_tile() -> void:
|
||||
# P1 #4: Peripheral-sector tile writes VIS_PERIPHERAL (180) to _vis_bytes.
|
||||
# P1 #4: Server simplified to forward-only (Sprint 22, #569). All visible
|
||||
# tiles are now written as VIS_FORWARD regardless of visibility_sectors value.
|
||||
# Peripheral sector is no longer a distinct visual state.
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
@@ -65,8 +67,8 @@ func test_fog_visibility_peripheral_tile() -> void:
|
||||
GameState.visibility_sectors = {Vector2i(10, 8): "Peripheral"}
|
||||
fog.update_from_state()
|
||||
assert_that(fog._vis_bytes[8 * 64 + 10]).override_failure_message(
|
||||
"Peripheral tile at (10,8) should be VIS_PERIPHERAL=%d" % FogState.VIS_PERIPHERAL
|
||||
).is_equal(FogState.VIS_PERIPHERAL)
|
||||
"All visible tiles write VIS_FORWARD after forward-only simplification"
|
||||
).is_equal(FogState.VIS_FORWARD)
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdsgybncfyu52
|
||||
@@ -0,0 +1 @@
|
||||
uid://b46mtkralmq14
|
||||
@@ -0,0 +1 @@
|
||||
uid://bh8tiio43gveq
|
||||
@@ -0,0 +1,238 @@
|
||||
## Sprint 22 — Entanglement ratio configuration acceptance tests (#175, #178)
|
||||
##
|
||||
## Test-first stubs for the client-side surface of the world_seed feature.
|
||||
## These tests will warn-and-skip until the implementation lands (Tyre, #175).
|
||||
##
|
||||
## Client-side acceptance criteria (#175):
|
||||
## - GameState carries a world_seed field (stores the seed for this session)
|
||||
## - SessionManager.new_game() generates and stores a world_seed
|
||||
## - The IPC startup payload carries world_seed so the server can seed SimRng
|
||||
##
|
||||
## Server-side acceptance criteria (#178) are in:
|
||||
## - server/src/content/entanglement.rs (Rust unit tests)
|
||||
##
|
||||
## Spec: D-029 (30/50/20 entanglement ratio, variable per seed), D-010 (deterministic sim)
|
||||
## Tickets: #175, #178
|
||||
class_name TestEntanglementSprint22
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Client-side: GameState.world_seed field (#175) ---------------------------
|
||||
|
||||
func test_game_state_has_world_seed_field() -> void:
|
||||
# #175 client-side: GameState must store the world_seed for this session.
|
||||
# The seed is set by SessionManager.new_game() and read by SimBridge to
|
||||
# carry it in the session startup IPC message.
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed not found — test-first stub (awaiting #175)")
|
||||
return
|
||||
# Field exists — verify it is numeric (int or null are both acceptable initial states)
|
||||
var seed_val = GameState.get("world_seed")
|
||||
assert_bool(seed_val == null or seed_val is int).override_failure_message(
|
||||
"GameState.world_seed must be int or null"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_game_state_world_seed_can_be_set_and_read() -> void:
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped (#175 not yet implemented)")
|
||||
return
|
||||
var orig = GameState.get("world_seed")
|
||||
GameState.world_seed = 0xDEADBEEF
|
||||
assert_int(GameState.world_seed).is_equal(0xDEADBEEF)
|
||||
# Restore
|
||||
GameState.world_seed = orig
|
||||
|
||||
|
||||
func test_game_state_world_seed_default_is_null_or_zero() -> void:
|
||||
# Before a session starts, world_seed should be null (no session) or 0 (unset).
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped")
|
||||
return
|
||||
var seed_val = GameState.get("world_seed")
|
||||
assert_bool(seed_val == null or seed_val == 0).override_failure_message(
|
||||
"GameState.world_seed should be null or 0 before any session starts"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Client-side: SessionManager seed generation (#175) -----------------------
|
||||
|
||||
func test_session_manager_exists() -> void:
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager autoload not found — skipped")
|
||||
return
|
||||
assert_that(sm).is_not_null()
|
||||
|
||||
|
||||
func test_session_manager_new_game_generates_world_seed() -> void:
|
||||
# #175: new_game() must generate and store world_seed in GameState.
|
||||
# The seed is a non-zero u64 that will be sent to the server on startup.
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
# Call new_game() (will create a save dir — acceptable in test environment)
|
||||
var orig_seed = GameState.get("world_seed")
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.new_game()
|
||||
var generated_seed = GameState.get("world_seed")
|
||||
|
||||
# world_seed must have been set to a non-null, non-zero value
|
||||
assert_bool(generated_seed != null).override_failure_message(
|
||||
"SessionManager.new_game() must set GameState.world_seed (#175)"
|
||||
).is_true()
|
||||
if generated_seed != null:
|
||||
assert_bool(generated_seed != 0).override_failure_message(
|
||||
"Generated world_seed must be non-zero"
|
||||
).is_true()
|
||||
|
||||
# Restore state
|
||||
GameState.current_game_id = orig_game_id
|
||||
GameState.world_seed = orig_seed
|
||||
|
||||
|
||||
func test_session_manager_same_game_id_has_same_seed() -> void:
|
||||
# Resuming a session must restore the original world_seed (not generate a new one).
|
||||
# This ensures deterministic replays work correctly (D-010).
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not sm.has_method("resume_game"):
|
||||
push_warning("TestEntanglementSprint22: resume_game() missing — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
# Set a known seed and game_id, then resume — seed must not be clobbered
|
||||
GameState.world_seed = 12345678
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.resume_game("20260228-120000-abc123")
|
||||
# resume_game() must NOT overwrite world_seed
|
||||
assert_int(GameState.world_seed).override_failure_message(
|
||||
"resume_game() must not overwrite world_seed — seed is loaded from the save, not regenerated"
|
||||
).is_equal(12345678)
|
||||
GameState.current_game_id = orig_game_id
|
||||
|
||||
|
||||
# -- IPC startup message: world_seed field (#175) ----------------------------
|
||||
|
||||
func test_protocol_encode_startup_message_has_world_seed_field() -> void:
|
||||
# #175 acceptance: startup IPC message must carry "world_seed" key.
|
||||
# Verifies Protocol.encode_startup_message encodes the seed so the server
|
||||
# can deserialize it as StartupMessage { world_seed: u64 }.
|
||||
var seed: int = 0xDEADBEEF # 3735928559 — fits in u32, safely maps to Rust u64
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(seed)
|
||||
assert_bool(bytes.size() > 0).override_failure_message(
|
||||
"Protocol.encode_startup_message must return non-empty bytes"
|
||||
).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).override_failure_message(
|
||||
"encode_startup_message output must be valid msgpack: %s" % str(decoded.status)
|
||||
).is_null()
|
||||
var msg = decoded.value
|
||||
assert_bool(msg is Dictionary and msg.has("world_seed")).override_failure_message(
|
||||
"StartupMessage wire payload must contain 'world_seed' key, got: %s" % str(msg)
|
||||
).is_true()
|
||||
assert_int(msg["world_seed"]).override_failure_message(
|
||||
"world_seed must round-trip through msgpack unchanged"
|
||||
).is_equal(seed)
|
||||
|
||||
|
||||
func test_protocol_encode_startup_message_zero_seed() -> void:
|
||||
# Edge case: seed=0 must still encode a valid payload (world_seed: 0).
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0)
|
||||
assert_bool(bytes.size() > 0).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).is_null()
|
||||
assert_int(decoded.value["world_seed"]).is_equal(0)
|
||||
|
||||
|
||||
func test_sim_bridge_can_send_world_seed_in_startup() -> void:
|
||||
# #175 acceptance: "startup IPC message carries a world_seed field"
|
||||
# The client must be able to include world_seed in the session startup payload.
|
||||
# Test-first: verify the API exists (method or field), else warn-and-skip.
|
||||
var sim_bridge = get_node_or_null("/root/SimBridge")
|
||||
if sim_bridge == null:
|
||||
push_warning("TestEntanglementSprint22: SimBridge not found — skipped")
|
||||
return
|
||||
|
||||
# Option A: SimBridge has a world_seed property that is sent during startup
|
||||
if "world_seed" in sim_bridge:
|
||||
sim_bridge.world_seed = 99999
|
||||
assert_int(sim_bridge.world_seed).is_equal(99999)
|
||||
sim_bridge.world_seed = 0
|
||||
return
|
||||
|
||||
# Option B: SimBridge has a set_world_seed() method
|
||||
if sim_bridge.has_method("set_world_seed"):
|
||||
# Method exists — this is the expected API
|
||||
sim_bridge.set_world_seed(99999)
|
||||
return
|
||||
|
||||
# Neither found — test-first stub
|
||||
push_warning(
|
||||
"TestEntanglementSprint22: SimBridge has no world_seed field or set_world_seed() — " +
|
||||
"test-first stub awaiting #175 implementation"
|
||||
)
|
||||
|
||||
|
||||
# -- Protocol: world_seed flows from client to server (#175) ------------------
|
||||
|
||||
func test_apply_snapshot_does_not_clobber_world_seed() -> void:
|
||||
# world_seed is set at session start and must persist across all subsequent snapshots.
|
||||
# Snapshots must not overwrite or clear the world_seed that was set at startup.
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
GameState.world_seed = 42000
|
||||
GameState.apply_snapshot({"tick": 5, "visible_tiles": []})
|
||||
assert_int(GameState.world_seed).override_failure_message(
|
||||
"apply_snapshot() must not clear or overwrite world_seed — seed is set once at session start"
|
||||
).is_equal(42000)
|
||||
GameState.world_seed = null
|
||||
|
||||
|
||||
# -- Seed variation property (#178, informational — full test is Rust-side) ---
|
||||
|
||||
func test_different_seeds_produce_different_configs_informational() -> void:
|
||||
# D-029: "entanglement rate varies per seed to prevent metagaming calibration"
|
||||
# The definitive acceptance test for this is Rust-side (server/src/content/entanglement.rs):
|
||||
# - EntanglementConfig::from_rng(seed_A) == EntanglementConfig::from_rng(seed_A) [deterministic]
|
||||
# - EntanglementConfig::from_rng(seed_A) != EntanglementConfig::from_rng(seed_B) [variable, >=90%]
|
||||
#
|
||||
# This test only verifies the client side: world_seed is a u64 large enough to
|
||||
# have sufficient entropy. A 24-bit game_id hex component alone has 16M combinations;
|
||||
# the full u64 seed provides 2^64 possibilities.
|
||||
#
|
||||
# We verify that two calls to new_game() produce different seeds.
|
||||
var sm = get_node_or_null("/root/SessionManager")
|
||||
if sm == null:
|
||||
push_warning("TestEntanglementSprint22: SessionManager not found — skipped")
|
||||
return
|
||||
if not "world_seed" in GameState:
|
||||
push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub")
|
||||
return
|
||||
|
||||
var orig_game_id: String = GameState.current_game_id
|
||||
sm.new_game()
|
||||
var seed_a = GameState.get("world_seed")
|
||||
sm.new_game()
|
||||
var seed_b = GameState.get("world_seed")
|
||||
|
||||
if seed_a == null or seed_b == null:
|
||||
push_warning("TestEntanglementSprint22: new_game() did not set world_seed — test-first stub")
|
||||
GameState.current_game_id = orig_game_id
|
||||
return
|
||||
|
||||
# Two different sessions should produce different seeds
|
||||
assert_bool(seed_a != seed_b).override_failure_message(
|
||||
"Two calls to new_game() must produce different world_seeds (D-029 anti-metagaming)"
|
||||
).is_true()
|
||||
GameState.current_game_id = orig_game_id
|
||||
@@ -0,0 +1 @@
|
||||
uid://hdoafqdfr6sq
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8e2watwphu41
|
||||
@@ -0,0 +1,517 @@
|
||||
## Sprint 22 — Fog system acceptance tests (#569)
|
||||
##
|
||||
## Validates FogState data management against the Sprint 22 acceptance criteria:
|
||||
## - Explored tiles never revert to unexplored black (EXP_EXPLORED persistence)
|
||||
## - Bounds grow-only invariant (explored tiles behind player stay in texture)
|
||||
## - All visible tiles written as Forward (server simplified to Forward-only)
|
||||
## - Exploration data survives texture resize (grow-only bounds copy)
|
||||
## - Shader file present with correct fog_alpha constant
|
||||
##
|
||||
## Spec: D-059 (fog shader), D-015 (vision cone), D-066 (dual-scale grid, 6-8 tile gradient)
|
||||
## Ticket: #569
|
||||
class_name TestFogSprint22
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func _get_fog_state() -> Node:
|
||||
var node = get_node_or_null("/root/FogState")
|
||||
if node == null:
|
||||
push_warning("TestFogSprint22: FogState autoload not found — test skipped (awaiting #569)")
|
||||
return node
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
# -- Spec constants (D-059) ---------------------------------------------------
|
||||
|
||||
func test_exp_explored_constant_is_128() -> void:
|
||||
# EXP_EXPLORED = 128 → shader reads this as ~0.502.
|
||||
# smoothstep(0.0, 0.2, 0.502) = 1.0 → exp_fade fully applied.
|
||||
# If EXP_EXPLORED were 0, explored tiles would render as solid unexplored black.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_EXPLORED).override_failure_message(
|
||||
"EXP_EXPLORED must be 128 — shader exp_fade requires explored value > 0.2 to avoid unexplored-black rendering"
|
||||
).is_equal(128)
|
||||
|
||||
|
||||
func test_exp_unexplored_constant_is_0() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_UNEXPLORED).is_equal(0)
|
||||
|
||||
|
||||
func test_exp_visible_constant_is_255() -> void:
|
||||
# EXP_VISIBLE = 255 → shader reads 1.0, full art visibility (currently in LOS)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.EXP_VISIBLE).is_equal(255)
|
||||
|
||||
|
||||
func test_vis_forward_constant_is_255() -> void:
|
||||
# D-059: VIS_FORWARD = 255 → clear vision, nearly transparent fog overlay
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.VIS_FORWARD).is_equal(255)
|
||||
|
||||
|
||||
func test_vis_hidden_constant_is_0() -> void:
|
||||
# D-059: VIS_HIDDEN = 0 → no vision, fog fully opaque
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
assert_int(fog_state.VIS_HIDDEN).is_equal(0)
|
||||
|
||||
|
||||
func test_unexplored_color_spec_value() -> void:
|
||||
# D-059: Unexplored = solid near-black #12141a
|
||||
# Verify the hex value decodes to the expected channel values.
|
||||
var c := Color("#12141a")
|
||||
assert_float(c.r).is_equal_approx(18.0 / 255.0, 0.003)
|
||||
assert_float(c.g).is_equal_approx(20.0 / 255.0, 0.003)
|
||||
assert_float(c.b).is_equal_approx(26.0 / 255.0, 0.003)
|
||||
# Sanity: it IS very dark (all channels < 0.12)
|
||||
assert_float(c.r).is_less(0.12)
|
||||
assert_float(c.g).is_less(0.12)
|
||||
assert_float(c.b).is_less(0.12)
|
||||
|
||||
|
||||
# -- Acceptance: explored tiles persist after leaving LOS (criterion 3) ------
|
||||
|
||||
func test_explored_tile_becomes_exp_explored_after_leaving_los() -> void:
|
||||
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
|
||||
# When tile (5,5) was in LOS (frame 1) and then leaves LOS (frame 2),
|
||||
# its exploration byte must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
push_warning("TestFogSprint22: update_from_state missing — skipped")
|
||||
return
|
||||
|
||||
# Frame 1: tile (5,5) is visible
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Frame 2: tile (5,5) leaves LOS
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Internal state check: _exp_bytes[tile(5,5)] must be EXP_EXPLORED (128)
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
push_warning("TestFogSprint22: _exp_bytes not accessible — data path untestable headlessly")
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
push_warning("TestFogSprint22: _width inaccessible — data path untestable")
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: tile (5,5) out of bounds after update — check grow_bounds margin")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx < 0 or idx >= exp_bytes.size():
|
||||
push_warning("TestFogSprint22: idx %d out of exp_bytes range %d" % [idx, exp_bytes.size()])
|
||||
return
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"Tile (5,5) must be EXP_EXPLORED=128 after leaving LOS — not EXP_UNEXPLORED=0 (#569 regression)"
|
||||
).is_equal(fog_state.EXP_EXPLORED)
|
||||
|
||||
|
||||
func test_explored_tile_is_exp_visible_while_in_los() -> void:
|
||||
# While in LOS, tile exploration byte must be EXP_VISIBLE (255)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(3, 3): true}
|
||||
GameState.visible_tiles = [{"x": 3, "y": 3, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 3 - ox
|
||||
var py := 3 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_VISIBLE)
|
||||
|
||||
|
||||
func test_unexplored_tile_stays_exp_unexplored() -> void:
|
||||
# Tile (7, 8) was never seen — must remain EXP_UNEXPLORED (0)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# See only (5, 5) — tile (7, 8) is not in LOS
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 7 - ox
|
||||
var py := 8 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_UNEXPLORED)
|
||||
|
||||
|
||||
# -- Acceptance: bounds grow-only invariant ------------------------------------
|
||||
|
||||
func test_bounds_never_shrink() -> void:
|
||||
# ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black"
|
||||
# Requires grow-only bounds: once a tile is in the texture, it stays there.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# Frame 1: see (10, 10) → establishes initial bounds
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b1: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Frame 2: see (30, 30) → bounds must expand to include both
|
||||
GameState.visible_positions = {Vector2i(30, 30): true}
|
||||
GameState.visible_tiles = [{"x": 30, "y": 30, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b2: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Frame 3: back to (10, 10) → bounds must NOT shrink
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
var b3: Rect2i = fog_state.map_bounds
|
||||
|
||||
assert_bool(b2.size.x >= b1.size.x).override_failure_message(
|
||||
"Bounds must grow when player moves to larger region"
|
||||
).is_true()
|
||||
assert_bool(b2.size.y >= b1.size.y).is_true()
|
||||
assert_bool(b3.size.x >= b2.size.x).override_failure_message(
|
||||
"Bounds must not shrink when player returns to previous position (grow-only invariant)"
|
||||
).is_true()
|
||||
assert_bool(b3.size.y >= b2.size.y).is_true()
|
||||
|
||||
|
||||
func test_bounds_include_margin_for_gradient_bleed() -> void:
|
||||
# D-066: 6-8 tile gradient at cone edge requires texture margin.
|
||||
# _grow_bounds adds 8-tile margin on each side (accommodates 7x7 Gaussian
|
||||
# kernel at 2-texel intervals = ±6 tile reach). After seeing (10,10),
|
||||
# bounds should extend at least 4 tiles beyond the visible tile.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var b: Rect2i = fog_state.map_bounds
|
||||
# With 4-tile margin: bounds.position.x <= 10 - 4 = 6
|
||||
assert_bool(b.position.x <= 6).override_failure_message(
|
||||
"FogState bounds must include 4-tile margin for gradient bleed (D-066 gradient spec)"
|
||||
).is_true()
|
||||
assert_bool(b.position.y <= 6).is_true()
|
||||
|
||||
|
||||
# -- Acceptance: Forward-only visibility (Sprint 22 server simplification) ----
|
||||
|
||||
func test_visible_tiles_written_as_vis_forward() -> void:
|
||||
# Sprint 22: server sends only Forward tiles (Peripheral sector removed).
|
||||
# FogState writes VIS_FORWARD (255) for all tiles in visible_positions.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
|
||||
GameState.visible_tiles = [
|
||||
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
|
||||
{"x": 6, "y": 5, "z": 0, "visibility": "Forward"},
|
||||
]
|
||||
fog_state.update_from_state()
|
||||
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
if vis_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
for pos in [Vector2i(5, 5), Vector2i(6, 5)]:
|
||||
var px := pos.x - ox
|
||||
var py := pos.y - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
continue
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < vis_bytes.size():
|
||||
assert_int(vis_bytes[idx]).override_failure_message(
|
||||
"All visible tiles should be VIS_FORWARD=255 — server is Forward-only in Sprint 22"
|
||||
).is_equal(fog_state.VIS_FORWARD)
|
||||
|
||||
|
||||
func test_tiles_outside_los_written_as_vis_hidden() -> void:
|
||||
# Tiles in bounds but not in visible_positions must be VIS_HIDDEN (0)
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (5, 7) is inside the padded bounds but not visible — must be VIS_HIDDEN
|
||||
var vis_bytes = fog_state.get("_vis_bytes")
|
||||
if vis_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 7 - oy
|
||||
if px >= 0 and py >= 0 and px < w:
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < vis_bytes.size():
|
||||
assert_int(vis_bytes[idx]).is_equal(fog_state.VIS_HIDDEN)
|
||||
|
||||
|
||||
# -- Acceptance: exploration survives texture resize --------------------------
|
||||
|
||||
func test_exploration_data_preserved_across_bounds_growth() -> void:
|
||||
# D-059: Texture resize must copy old exploration bytes into new texture.
|
||||
# Without this, tiles seen before a resize appear as EXP_UNEXPLORED (black).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
# Frame 1: see (5, 5), then leave
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visible_tiles = []
|
||||
fog_state.update_from_state() # (5,5) → EXP_EXPLORED
|
||||
|
||||
# Frame 2: move far away — forces bounds growth (resize)
|
||||
GameState.visible_positions = {Vector2i(80, 80): true}
|
||||
GameState.visible_tiles = [{"x": 80, "y": 80, "z": 0, "visibility": "Forward"}]
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (5,5) must still be EXP_EXPLORED after the resize
|
||||
var exp_bytes = fog_state.get("_exp_bytes")
|
||||
if exp_bytes == null:
|
||||
return
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1
|
||||
if w <= 0:
|
||||
return
|
||||
var px := 5 - ox
|
||||
var py := 5 - oy
|
||||
if px < 0 or py < 0 or px >= w:
|
||||
push_warning("TestFogSprint22: (5,5) not in bounds after resize — is copy-on-resize working?")
|
||||
return
|
||||
var idx := py * w + px
|
||||
if idx >= 0 and idx < exp_bytes.size():
|
||||
assert_int(exp_bytes[idx]).override_failure_message(
|
||||
"Exploration data at (5,5) must survive bounds growth — EXP_EXPLORED (128) expected after resize"
|
||||
).is_greater_equal(fog_state.EXP_EXPLORED)
|
||||
|
||||
|
||||
# -- Shader file checks (D-059) -----------------------------------------------
|
||||
|
||||
func test_fog_gdshader_exists() -> void:
|
||||
assert_bool(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message(
|
||||
"fog.gdshader must exist — fog rendering requires this shader file (#569)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_fog_alpha() -> void:
|
||||
# D-059: explored fog overlay must be ~25-30% opacity.
|
||||
# fog_alpha constant controls this. Verify the shader defines it.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — shader check skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
push_warning("TestFogSprint22: fog.gdshader is empty or unreadable")
|
||||
return
|
||||
assert_bool(source.contains("fog_alpha")).override_failure_message(
|
||||
"fog.gdshader must define fog_alpha for the 25-30%% explored-tile overlay (D-059)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_smoothstep_clarity_ramp() -> void:
|
||||
# D-059/D-066: smooth gradient requires a clarity ramp (smoothstep).
|
||||
# The blurred visibility → clarity ramp must use smoothstep for smooth gradients.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
assert_bool(source.contains("smoothstep")).override_failure_message(
|
||||
"fog.gdshader must use smoothstep for the clarity ramp — hard steps violate D-066 gradient spec"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_defines_unexplored_color() -> void:
|
||||
# D-059: unexplored = solid near-black #12141a.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
assert_bool(source.contains("UNEXPLORED_COLOR")).override_failure_message(
|
||||
"fog.gdshader must define UNEXPLORED_COLOR constant (D-059 #12141a spec)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_fog_shader_uses_gaussian_blur_for_gradient() -> void:
|
||||
# D-066: 6-8 tile soft gradient requires Gaussian blur on visibility texture.
|
||||
# Current implementation: 7x7 kernel at 2-texel intervals (±6 tiles), sigma 2.0
|
||||
# in kernel space = 4.0 tiles effective. At 2-sigma (8 tiles), weight drops to 0.14.
|
||||
# This covers the D-066 "6-8 tile" gradient spec.
|
||||
if not ResourceLoader.exists("res://shaders/fog.gdshader"):
|
||||
push_warning("TestFogSprint22: fog.gdshader not found — skipped")
|
||||
return
|
||||
var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader")
|
||||
if source.is_empty():
|
||||
return
|
||||
# 7x7 Gaussian uses dy from -3 to 3
|
||||
assert_bool(source.contains("sample_visibility")).override_failure_message(
|
||||
"fog.gdshader must call sample_visibility() for Gaussian-blurred visibility (D-066 gradient)"
|
||||
).is_true()
|
||||
assert_bool(source.contains("-3.0")).override_failure_message(
|
||||
"fog.gdshader sample_visibility must use 7x7 kernel (±3 tiles) for 6-tile gradient coverage (D-066)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Regression: GameState visible_positions (existing contract) ---------------
|
||||
|
||||
func test_visible_positions_derived_from_visible_tiles_in_server_mode() -> void:
|
||||
# D-020: In real server mode, visible_positions derives from visible_tiles.
|
||||
# Fog rendering depends on this derivation being correct.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 10,
|
||||
"visible_tiles": [
|
||||
{"x": 7, "y": 7, "z": 0, "visibility": "Forward"},
|
||||
{"x": 8, "y": 7, "z": 0, "visibility": "Forward"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(7, 7))).override_failure_message(
|
||||
"visible_positions must be derived from visible_tiles when no explicit visible_positions key"
|
||||
).is_true()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(8, 7))).is_true()
|
||||
|
||||
|
||||
func test_visibility_sectors_populated_forward_only() -> void:
|
||||
# D-015: visibility_sectors must be populated from visible_tiles.
|
||||
# In Forward-only mode, all sectors are "Forward".
|
||||
GameState.apply_snapshot({
|
||||
"tick": 11,
|
||||
"visible_tiles": [
|
||||
{"x": 4, "y": 4, "z": 0, "visibility": "Forward"},
|
||||
],
|
||||
})
|
||||
assert_bool(GameState.visibility_sectors.has(Vector2i(4, 4))).is_true()
|
||||
assert_str(GameState.visibility_sectors[Vector2i(4, 4)]).is_equal("Forward")
|
||||
|
||||
|
||||
func test_visible_positions_cleared_on_new_snapshot() -> void:
|
||||
# Old positions from tick N must not persist to tick N+1
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}],
|
||||
})
|
||||
assert_int(GameState.visible_positions.size()).is_equal(1)
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
|
||||
})
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message(
|
||||
"Old visible positions must be cleared when new visible_tiles arrive"
|
||||
).is_false()
|
||||
assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
|
||||
|
||||
|
||||
# -- Performance (D-059) -------------------------------------------------------
|
||||
|
||||
func test_fog_state_update_under_2ms_for_400_tiles() -> void:
|
||||
# D-059: <1ms/frame CPU budget for fog update. Allow 2x margin for test env.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
if not fog_state.has_method("update_from_state"):
|
||||
return
|
||||
|
||||
var positions: Dictionary = {}
|
||||
var tiles: Array = []
|
||||
for x in range(20):
|
||||
for y in range(20):
|
||||
positions[Vector2i(x, y)] = true
|
||||
tiles.append({"x": x, "y": y, "z": 0, "visibility": "Forward"})
|
||||
GameState.visible_positions = positions
|
||||
GameState.visible_tiles = tiles
|
||||
|
||||
var start := Time.get_ticks_usec()
|
||||
fog_state.update_from_state()
|
||||
var elapsed_ms := (Time.get_ticks_usec() - start) / 1000.0
|
||||
|
||||
assert_float(elapsed_ms).override_failure_message(
|
||||
"FogState.update_from_state() must complete in <2ms for 400 tiles (spec: <1ms D-059)"
|
||||
).is_less(2.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvt7ytxff0id
|
||||
@@ -0,0 +1 @@
|
||||
uid://c6lle6hfc1cf6
|
||||
@@ -0,0 +1 @@
|
||||
uid://cp6n6uo6pfnee
|
||||
@@ -0,0 +1 @@
|
||||
uid://cao5jf5h36img
|
||||
@@ -0,0 +1 @@
|
||||
uid://dm46ip672i3jc
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd42lh2licl65
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7lnnr2apbyqw
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmd5ka0xckcg5
|
||||
@@ -0,0 +1 @@
|
||||
uid://c84lxgbhgf3bw
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8snipm64g2b2
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3rq8w40d20yw
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnkfuyqbrcr3v
|
||||
@@ -0,0 +1 @@
|
||||
uid://dguvww6h7nxyr
|
||||
@@ -0,0 +1,270 @@
|
||||
## Visual test capture engine — boots main.tscn, runs a named scenario or flow,
|
||||
## captures viewport PNGs for golden comparison or ad-hoc inspection.
|
||||
##
|
||||
## Usage:
|
||||
## godot --rendering-driver opengl3 --fixed-fps 60 --resolution 960x540 \
|
||||
## --path client -s res://tests/visual_capture.gd -- \
|
||||
## --scenario fog_3state --output /abs/path
|
||||
##
|
||||
## godot ... -- --flow flow_dialogue --output /abs/path --interval 3
|
||||
## godot ... -- --list
|
||||
##
|
||||
## Follows gen_client_fixtures.gd pattern: extends SceneTree, _init -> _run, quit().
|
||||
## Requires a real rendering driver (not --headless) for shader execution.
|
||||
extends SceneTree
|
||||
|
||||
# Load VisualScenarios explicitly — class_name registry not populated in -s mode.
|
||||
# Must create an instance: static methods on loaded GDScript resources aren't
|
||||
# callable via variable (only via class_name, which isn't available in -s mode).
|
||||
var _scenarios: RefCounted = null
|
||||
|
||||
var _output_dir: String = ""
|
||||
var _scenario: String = ""
|
||||
var _flow: String = ""
|
||||
var _interval: float = 3.0
|
||||
var _list_mode: bool = false
|
||||
var _config: Dictionary = {}
|
||||
|
||||
|
||||
func _init():
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run():
|
||||
# Parse CLI args (after -- separator)
|
||||
_parse_args()
|
||||
|
||||
# Load scenarios script and create instance (class_name not available in -s mode)
|
||||
var scenarios_script: GDScript = load("res://tests/visual_scenarios.gd")
|
||||
_scenarios = scenarios_script.new()
|
||||
|
||||
# Load config
|
||||
_config = _load_config()
|
||||
if _config.is_empty():
|
||||
push_error("visual_capture: failed to load tests/visual.json")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# List mode: print scenario/flow names and exit
|
||||
if _list_mode:
|
||||
_print_list()
|
||||
quit()
|
||||
return
|
||||
|
||||
# Validate mode
|
||||
if _scenario.is_empty() and _flow.is_empty():
|
||||
push_error("visual_capture: specify --scenario NAME or --flow NAME (or --list)")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
if _output_dir.is_empty():
|
||||
push_error("visual_capture: --output DIR is required")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# Ensure output directory exists
|
||||
DirAccess.make_dir_recursive_absolute(_output_dir)
|
||||
|
||||
# Load main scene (autoloads already initialized from project.godot)
|
||||
var main_scene = load("res://scenes/main.tscn")
|
||||
if main_scene == null:
|
||||
push_error("visual_capture: failed to load res://scenes/main.tscn")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var main_node = main_scene.instantiate()
|
||||
root.add_child(main_node)
|
||||
|
||||
# Wait for NoiseTexture2D async generation
|
||||
var fog_overlay = main_node.get_node_or_null("World/FogOverlay")
|
||||
if fog_overlay and not fog_overlay._noise_ready:
|
||||
print("visual_capture: waiting for fog noise texture...")
|
||||
await fog_overlay.fog_noise_ready
|
||||
print("visual_capture: fog noise ready")
|
||||
|
||||
# Set deterministic shader time BEFORE settle frames
|
||||
# Autoload globals not available as identifiers in -s mode — use tree path.
|
||||
root.get_node("/root/FogState").override_time = 1.0
|
||||
|
||||
# Settle frames (camera smoothing, fog uniform init, UI layout)
|
||||
var settle_count: int = _config.get("settle_frames", 30)
|
||||
print("visual_capture: settling %d frames..." % settle_count)
|
||||
for i in range(settle_count):
|
||||
await process_frame
|
||||
|
||||
if not _scenario.is_empty():
|
||||
await _run_scenario(main_node)
|
||||
elif not _flow.is_empty():
|
||||
await _run_flow(main_node)
|
||||
|
||||
quit()
|
||||
|
||||
|
||||
func _run_scenario(_main_node: Node) -> void:
|
||||
var scenarios: Dictionary = _config.get("scenarios", {})
|
||||
if not scenarios.has(_scenario):
|
||||
push_error("visual_capture: unknown scenario '%s'" % _scenario)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var scenario_cfg: Dictionary = scenarios[_scenario]
|
||||
var ticks: int = scenario_cfg.get("ticks", 5)
|
||||
|
||||
print("visual_capture: scenario '%s' — %d ticks" % [_scenario, ticks])
|
||||
|
||||
# Apply scenario setup hook (pass root for autoload access)
|
||||
if not _scenarios.apply_setup(_scenario, root):
|
||||
push_warning("visual_capture: no setup hook for '%s'" % _scenario)
|
||||
|
||||
# Advance simulation ticks (each process_frame polls one snapshot)
|
||||
for i in range(ticks):
|
||||
await process_frame
|
||||
|
||||
# Post-tick setup (e.g. zone tint patching)
|
||||
_scenarios.post_setup(_scenario, root)
|
||||
|
||||
# Extra frames for state propagation + viewport texture lag
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
# Capture
|
||||
await _capture("%s/%s.png" % [_output_dir, _scenario])
|
||||
|
||||
|
||||
func _run_flow(_main_node: Node) -> void:
|
||||
var flows: Dictionary = _config.get("flows", {})
|
||||
if not flows.has(_flow):
|
||||
push_error("visual_capture: unknown flow '%s'" % _flow)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var flow_cfg: Dictionary = flows[_flow]
|
||||
var steps: Array = flow_cfg.get("steps", [])
|
||||
var interval: float = flow_cfg.get("interval", _interval)
|
||||
|
||||
print("visual_capture: flow '%s' — %d steps, %.1fs interval" % [_flow, steps.size(), interval])
|
||||
|
||||
# Create output subdirectory for flow frames
|
||||
var flow_dir := "%s/%s" % [_output_dir, _flow]
|
||||
DirAccess.make_dir_recursive_absolute(flow_dir)
|
||||
|
||||
var manifest_lines: PackedStringArray = []
|
||||
var frame_idx: int = 0
|
||||
|
||||
for step in steps:
|
||||
if not step is Dictionary:
|
||||
continue
|
||||
var action: String = step.get("action", "wait")
|
||||
var label: String = step.get("label", action)
|
||||
|
||||
# Apply action
|
||||
_scenarios.apply_flow_action(action, root)
|
||||
|
||||
# Wait for interval (timer yields back to main loop — frames keep rendering)
|
||||
await create_timer(interval).timeout
|
||||
|
||||
# Extra frames for viewport texture to catch up
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
# Capture frame
|
||||
var frame_name := "%s_%03d.png" % [_flow, frame_idx]
|
||||
await _capture("%s/%s" % [flow_dir, frame_name])
|
||||
|
||||
# Build manifest line
|
||||
var total_seconds: int = int((frame_idx + 1) * interval)
|
||||
var timecode := "%d:%02d" % [total_seconds / 60, total_seconds % 60]
|
||||
manifest_lines.append("%03d %s %s" % [frame_idx, timecode, label])
|
||||
|
||||
frame_idx += 1
|
||||
|
||||
# Write manifest sidecar
|
||||
var manifest_path := "%s/%s_manifest.txt" % [flow_dir, _flow]
|
||||
var f := FileAccess.open(manifest_path, FileAccess.WRITE)
|
||||
if f:
|
||||
f.store_string("\n".join(manifest_lines) + "\n")
|
||||
f.close()
|
||||
print("visual_capture: manifest -> %s" % manifest_path)
|
||||
|
||||
|
||||
func _capture(path: String) -> void:
|
||||
# Wait for GPU to finish rendering current frame
|
||||
await RenderingServer.frame_post_draw
|
||||
|
||||
var image := root.get_viewport().get_texture().get_image()
|
||||
if image == null:
|
||||
push_error("visual_capture: viewport image is null — GPU rendering may not be available")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var err := image.save_png(path)
|
||||
if err != OK:
|
||||
push_error("visual_capture: failed to save PNG to %s (error %d)" % [path, err])
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("visual_capture: captured -> %s (%dx%d)" % [path, image.get_width(), image.get_height()])
|
||||
|
||||
|
||||
func _load_config() -> Dictionary:
|
||||
# Config is at tests/visual.json relative to repo root.
|
||||
# Repo root = parent of Godot project root (client/).
|
||||
var project_root := ProjectSettings.globalize_path("res://")
|
||||
var repo_root := project_root.rstrip("/").get_base_dir()
|
||||
var config_path := repo_root.path_join("tests/visual.json")
|
||||
|
||||
var f := FileAccess.open(config_path, FileAccess.READ)
|
||||
if f == null:
|
||||
push_error("visual_capture: cannot open %s" % config_path)
|
||||
return {}
|
||||
|
||||
var json_text := f.get_as_text()
|
||||
f.close()
|
||||
|
||||
var parsed = JSON.parse_string(json_text)
|
||||
if parsed == null or not parsed is Dictionary:
|
||||
push_error("visual_capture: invalid JSON in %s" % config_path)
|
||||
return {}
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
func _parse_args() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
match args[i]:
|
||||
"--scenario":
|
||||
i += 1
|
||||
if i < args.size():
|
||||
_scenario = args[i]
|
||||
"--flow":
|
||||
i += 1
|
||||
if i < args.size():
|
||||
_flow = args[i]
|
||||
"--output":
|
||||
i += 1
|
||||
if i < args.size():
|
||||
_output_dir = args[i]
|
||||
"--interval":
|
||||
i += 1
|
||||
if i < args.size():
|
||||
_interval = float(args[i])
|
||||
"--list":
|
||||
_list_mode = true
|
||||
_:
|
||||
push_warning("visual_capture: unknown arg '%s'" % args[i])
|
||||
i += 1
|
||||
|
||||
|
||||
func _print_list() -> void:
|
||||
var scenarios: Dictionary = _config.get("scenarios", {})
|
||||
var flows: Dictionary = _config.get("flows", {})
|
||||
print("scenarios:")
|
||||
for name in scenarios:
|
||||
var desc: String = scenarios[name].get("description", "")
|
||||
print(" %s — %s" % [name, desc])
|
||||
print("flows:")
|
||||
for name in flows:
|
||||
var steps: Array = flows[name].get("steps", [])
|
||||
print(" %s — %d steps" % [name, steps.size()])
|
||||
@@ -0,0 +1,143 @@
|
||||
extends RefCounted
|
||||
## Setup hooks for visual test scenarios.
|
||||
## Config (tests/visual.json) controls WHAT to run; this file controls HOW to
|
||||
## set up each scenario in-engine. Called by visual_capture.gd after main.tscn
|
||||
## is loaded and settled.
|
||||
##
|
||||
## Autoload globals (FogState, SimBridge, GameState) are NOT available as
|
||||
## compile-time identifiers in -s mode. All methods receive tree_root and
|
||||
## look up autoloads via get_node("/root/...").
|
||||
|
||||
## Apply scenario-specific setup before tick advancement.
|
||||
## Returns true if the scenario is recognized, false otherwise.
|
||||
func apply_setup(scenario_name: String, tree_root: Node) -> bool:
|
||||
var sim_bridge := tree_root.get_node("/root/SimBridge")
|
||||
var fog_state := tree_root.get_node("/root/FogState")
|
||||
var game_state := tree_root.get_node("/root/GameState")
|
||||
|
||||
match scenario_name:
|
||||
"fog_3state":
|
||||
# Default TestHarness state — player at (10,10), 8x8 room.
|
||||
# All 3 fog states naturally visible: clear (cone), explored (out of cone), unexplored.
|
||||
pass
|
||||
|
||||
"fog_diagonal":
|
||||
# Diagonal LOS boundary — staircase regression target.
|
||||
# Default room works: the diamond-shaped visibility radius creates diagonal edges.
|
||||
# 10 ticks builds enough explored tiles to show the boundary.
|
||||
pass
|
||||
|
||||
"fog_boundary":
|
||||
# Explored/unexplored transition centered.
|
||||
# Move player south so the northern explored boundary is center-frame.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(10, 12)
|
||||
|
||||
"fog_zone_tint":
|
||||
# Bar zone warm amber tint (D-046).
|
||||
# Inject zone_id into visible_tiles so fog_state.gd writes bar tint.
|
||||
# TestHarness tiles don't include zone_id by default — patch them.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(10, 10)
|
||||
# Zone tint is written from visible_tiles with zone_id field.
|
||||
# We patch GameState.visible_tiles after the first snapshot in post_setup().
|
||||
pass
|
||||
|
||||
"fog_debug":
|
||||
# Raw exploration overlay (green/blue/red).
|
||||
fog_state.debug_exploration = true
|
||||
|
||||
"npc_in_fog":
|
||||
# NPC sprite visible above fog (D-033).
|
||||
# Position player so NPC at (12,9) is at the fog edge.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(10, 10)
|
||||
|
||||
"hud_default":
|
||||
# Full HUD: time, Careful stance, minimap, monologue.
|
||||
# Tick 1 triggers monologue from TestHarness.
|
||||
# Inject Careful stance for visible green indicator.
|
||||
game_state.player_stance = "Careful"
|
||||
|
||||
"dialogue_open":
|
||||
# Dialogue box + interaction prompt + key bar.
|
||||
# Move adjacent to NPC and interact.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(11, 9)
|
||||
sim_bridge.harness.process_input("Interact")
|
||||
|
||||
"dialogue_with_monologue":
|
||||
# Confrontation beat: dialogue + monologue overlap.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(11, 9)
|
||||
sim_bridge.harness.process_input("Interact")
|
||||
# Monologue will be injected in post_setup after dialogue is active.
|
||||
|
||||
"minimap_stance":
|
||||
# Minimap + Sprint stance indicator overlap (top-right).
|
||||
game_state.player_stance = "Sprint"
|
||||
|
||||
"cursor_menu":
|
||||
# Cursor rendering over dialogue option area.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(11, 9)
|
||||
sim_bridge.harness.process_input("Interact")
|
||||
|
||||
_:
|
||||
push_warning("VisualScenarios: unknown scenario '%s'" % scenario_name)
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Post-tick setup — called after N ticks have been advanced.
|
||||
## Use for state that depends on tick processing (e.g. zone tint patching).
|
||||
func post_setup(scenario_name: String, tree_root: Node) -> void:
|
||||
var fog_state := tree_root.get_node("/root/FogState")
|
||||
var game_state := tree_root.get_node("/root/GameState")
|
||||
|
||||
match scenario_name:
|
||||
"fog_zone_tint":
|
||||
# Patch visible_tiles with zone_id for fog_state.gd zone tint pipeline.
|
||||
var patched: Array = []
|
||||
for tile in game_state.visible_tiles:
|
||||
if tile is Dictionary:
|
||||
var t: Dictionary = tile.duplicate()
|
||||
t["zone_id"] = "bar"
|
||||
patched.append(t)
|
||||
game_state.visible_tiles = patched
|
||||
# Force fog_state to re-process with zone tint data
|
||||
fog_state.update_from_state()
|
||||
|
||||
"dialogue_with_monologue":
|
||||
# Inject monologue to create the overlap condition.
|
||||
game_state.current_monologue = {
|
||||
"id": "test_confrontation",
|
||||
"text": "Something about that answer doesn't add up.",
|
||||
"duration_seconds": 5.0,
|
||||
"priority": "high",
|
||||
"is_urgent": true,
|
||||
}
|
||||
|
||||
"hud_default":
|
||||
# Ensure stance is still Careful after ticks (TestHarness may override).
|
||||
game_state.player_stance = "Careful"
|
||||
|
||||
|
||||
## Apply flow action — called for each step in a movie flow.
|
||||
func apply_flow_action(action: String, tree_root: Node) -> void:
|
||||
var sim_bridge := tree_root.get_node("/root/SimBridge")
|
||||
|
||||
match action:
|
||||
"wait":
|
||||
pass # No-op — just captures current state
|
||||
|
||||
"select_option_1":
|
||||
# Simulate selecting the first dialogue option
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.process_input("DialogueOption1")
|
||||
|
||||
_:
|
||||
# Movement actions and Interact pass through to TestHarness
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.process_input(action)
|
||||
@@ -0,0 +1 @@
|
||||
uid://wqekxebyn6to
|
||||
@@ -0,0 +1 @@
|
||||
uid://2hd0mfw5x245
|
||||
@@ -0,0 +1 @@
|
||||
uid://8jhxbtbelw0y
|
||||
@@ -0,0 +1 @@
|
||||
uid://dw1caicc2s7qf
|
||||
@@ -1,7 +1,8 @@
|
||||
# Triangle 2: Worried Knowledge — unreported evidence and protective silence
|
||||
# Active fork — Sera's choice about what to do with what she knows
|
||||
# Triangle 3: Worried Knowledge — unreported evidence and protective silence
|
||||
# Passive tension (D-087) — background pressure, not a direct player decision point
|
||||
canonical_id: worried-knowledge
|
||||
display_name: "Worried Knowledge"
|
||||
classification: passive_tension
|
||||
description: >
|
||||
Sera Venn (Commission field tech, detective's FRIEND) is sitting on
|
||||
unreported evidence about Kael's manifest discrepancies. She's protecting
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Triangle 4: Worried Partner — ring pressure on personal relationships
|
||||
# Active fork — the emotional heart of the smuggler's FRIEND arc
|
||||
# Triangle 5: Worried Partner — ring pressure on personal relationships
|
||||
# Passive tension (D-087) — background pressure, not a direct player decision point
|
||||
canonical_id: worried-partner
|
||||
display_name: "Worried Partner"
|
||||
classification: passive_tension
|
||||
description: >
|
||||
Kael Davan (dock worker, ring member, smuggler's FRIEND) is under
|
||||
increasing ring pressure from Devra (ring coordinator). Naia Tamm
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../tooling/db
|
||||
@@ -15,7 +15,11 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
|
||||
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084, D-090, D-092, D-093, D-095, D-098, D-104, D-105, D-107 |
|
||||
| [scope.md](scope.md) | Game concept, prototype | 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, D-087, D-089, D-091 |
|
||||
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
|
||||
| [questions.md](questions.md) | Open questions | Q-001 through Q-050 |
|
||||
| [questions.md](questions.md) | Open questions (index) | Q-001 through Q-054 |
|
||||
| [questions-architecture.md](questions-architecture.md) | Technical questions | Q-001, Q-006, Q-009, Q-018–Q-023, Q-029, Q-030, Q-046 |
|
||||
| [questions-perception.md](questions-perception.md) | Observation questions | Q-003, Q-014, Q-016, Q-024–Q-026, Q-051, Q-053, Q-054 |
|
||||
| [questions-content.md](questions-content.md) | Narrative questions | Q-010, Q-012–Q-015, Q-017, Q-028, Q-031, Q-033, Q-040–Q-050, Q-052 |
|
||||
| [questions-scope.md](questions-scope.md) | Scope questions | Q-002, Q-004, Q-005, Q-007, Q-008, Q-011, Q-027, Q-032, Q-034–Q-039 |
|
||||
| [rejected.md](rejected.md) | Rejected alternatives | R-001 through R-010 |
|
||||
|
||||
## Querying Decisions
|
||||
|
||||
@@ -69,6 +69,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- `ObserverSnapshot`: the only data structure crossing the boundary. Contains visible entities, fog state, sound events, monologue triggers, HUD widget data. Variable shape per character build.
|
||||
- `PlayerInput`: semantic actions (MoveNorth, Interact, UsePerceptionMode), not raw key events. Timestamped for deterministic processing.
|
||||
- `SimBridge` trait: abstracts transport. `LocalBridge` (subprocess, channels) and `NetworkBridge` (TCP, MessagePack) implement the same interface.
|
||||
- **Protocol versioning policy:** `PROTOCOL_VERSION` gates **wire format compatibility** — field names, types, message structure. Bump when deserialization would fail (added/removed/renamed fields, new enum variants, changed types). Do NOT bump for gameplay parameter changes that affect what data flows through the same format (vision cone angles, NPC behavior, map layout, balance tuning). Per-character variation (D-015 cone config, D-017 perception modes) means these parameters differ between entities on the same server simultaneously — they are game state, not protocol. The client renders whatever `ObserverSnapshot` the server sends; it has no awareness of cone angles or perception mode configuration.
|
||||
- **Kill switch:** If no working prototype (character + fog + one NPC) exists by week 8 of development, pivot to pure Godot. If bridge/sync code exceeds game logic for 3 consecutive sprints, the architecture tax is too high.
|
||||
- **Development sequence:**
|
||||
1. Build Rust simulation as standalone binary (testable via terminal/logs)
|
||||
@@ -323,7 +324,8 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Source:** Generator Architecture Workshop (#562), 2026-02-27. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-13.
|
||||
- **Raised by:** Tyre (struct design), Miri (cultural grammar — `miri-round4.md`), Nigel (replayability requirements), Ozzie (player experience). Visual grammar: Araminta (`araminta-round4.md` §2).
|
||||
- **Dissent:** Nigel initially proposed instanced districts for vessels; lead ruled entity-carried MobileChunk for persistence.
|
||||
- **Cross-reference:** D-100 (DamageOverlay applies to vessel damage), D-109 (LocalOverlay mandate), Q-046 (departure schedule — resolved by this D-record)
|
||||
- **Note:** The `Idle` movement state is the canonical primitive for player-owned stationary installations (space stations, orbital platforms, parked vessels as permanent bases). A MobileChunk in `Idle` with no `scheduled_departure` is architecturally equivalent to a static chunk from the simulation's perspective — it participates in the same tile/zone system. This design prevents future over-engineering of a separate 'player installation' system.
|
||||
- **Cross-reference:** D-100 (DamageOverlay applies to vessel damage), D-109 (LocalOverlay mandate), D-111 (MobileChunk Idle state as stationary installation primitive), Q-046 (departure schedule — resolved by this D-record)
|
||||
|
||||
### D-109: DamageOverlay / RegenerationStrategy Prohibition — Architectural Mandate
|
||||
- **Date:** 2026-02-27
|
||||
@@ -334,6 +336,42 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Dissent:** None.
|
||||
- **Cross-reference:** D-100 (DamageOverlay + RegenerationStrategy full specification)
|
||||
|
||||
### D-110: Signed Z-Level Addressing — base_z u8 → i8
|
||||
- **Date:** 2026-02-27
|
||||
- **Decision:** All z-level base fields use signed integers (`i8`) instead of unsigned (`u8`). Specifically: `MultiBlockReservation.base_z: i8`, `FloorZone.z_level: i8`, and any struct that references a z-level position (not a count). Z-level *counts* (`z_levels: u8` on DistrictSkeleton) remain unsigned — they represent "how many floors", which is always positive. The distinction: `base_z` is "where does the bottom floor start" (can be negative for basements/subterranean spaces), `z_levels` is "how many floors total" (always ≥1).
|
||||
- **Rationale:** The Round 4 workshop designs (D-106, D-108) and the gestalt gas explosion example explicitly assume negative z-levels (basements at z=-1 to z=-3, utility sub-levels). The existing `u8` type contradicts the design intent. A deep mine is structurally an inverted skyscraper with `base_z: -30, z_levels: 30`. Lazy z-level loading (`ZLevelLoadState`) works identically for negative z — the loading system cares about relative offsets between adjacent levels, not the sign.
|
||||
- **Source:** Tyre architectural analysis, 2026-02-27. Confirmed existing Round 4 design intent (D-106 §height tiers, Gestalt Round 4 §gas explosion, Araminta Round 2 §infrastructure routing).
|
||||
- **Raised by:** Tyre (type-level fix), lead (edge case prompt: basements, deep mines).
|
||||
- **Dissent:** None.
|
||||
- **Cross-reference:** D-106 (vertical scale architecture), D-094 (spatial hierarchy), D-108 (MobileChunk)
|
||||
|
||||
### D-111: MobileChunk Idle State Covers Stationary Player Installations
|
||||
- **Date:** 2026-02-27
|
||||
- **Decision:** The `MobileChunk` `Idle` movement state (D-108) is the canonical primitive for player-owned stationary installations — space stations, orbital platforms, parked vessels used as bases, or any persistent interior space the player controls that is not part of the district chunk grid. A stationary installation is a MobileChunk that does not move: it uses the same streaming, save/load, NPC simulation, and `LocalOverlay` modification system as vessels. Player construction within a MobileChunk (building rooms, placing equipment) requires the DLC construction system to emit valid `LocalOverlay` modifications — the architectural pattern is ready, the construction system is DLC scope.
|
||||
- **Rationale:** A separate "location instance" system for player bases would require a new coordinate system, new streaming/loading path, new save/load path, portal/transition logic, and duplicate pathfinding/perception/simulation — Tier 4 difficulty for zero benefit over existing primitives. The `Idle` state already exists in D-108; documenting its design intent for stationary installations costs nothing and prevents future over-engineering.
|
||||
- **Source:** Tyre architectural analysis, 2026-02-27.
|
||||
- **Raised by:** Tyre (architectural mapping), lead (edge case prompt: player bases, fortresses).
|
||||
- **Dissent:** None.
|
||||
- **Cross-reference:** D-108 (MobileChunk specification — `Idle` state), D-100/D-109 (LocalOverlay for modifications)
|
||||
|
||||
### D-112: No Separate Location Instancing System
|
||||
- **Date:** 2026-02-27
|
||||
- **Decision:** The game does **not** use instanced locations (separate spatial domains outside the world chunk grid). All subterranean spaces, player bases, and special locations are represented using existing primitives:
|
||||
- **Basements / sub-levels:** `MultiBlockReservation` with negative `base_z` (D-110)
|
||||
- **Deep mines (shaft-style):** Downward `MultiBlockReservation`, lazy-loaded via `ZLevelLoadState`
|
||||
- **Deep mines (cave network):** Organic-mode district (D-096) with mine-specific template
|
||||
- **Player base in existing building:** `LocalOverlay` modifications (D-100/D-109)
|
||||
- **Player base as hidden bunker:** `MultiBlockReservation` with negative z, generated at world-gen
|
||||
- **Player-built space:** `LocalOverlay` + construction system (DLC scope)
|
||||
- **Stationary installation:** `MobileChunk` in `Idle` state (D-111)
|
||||
- **Vessel interior:** `MobileChunk` (D-108)
|
||||
- **Rationale:** Instanced locations would require: (1) new coordinate system for instanced space, (2) new streaming/loading path, (3) new save/load path, (4) portal/transition logic between world space and instanced space, (5) duplicate pathfinding, perception, and simulation tier logic. Every edge case maps cleanly to an existing primitive. Nigel's vessel instancing proposal was already ruled out in favor of entity-carried MobileChunk (D-108 dissent note); the same reasoning extends to all "separate space" cases.
|
||||
- **DLC/Mod implication:** `LocalOverlay` is the universal post-generation modification layer. DLC quest locations, mod-injected dungeons, and scenario-specific spaces can be delivered as overlay packages applied to existing districts under strict conditions — without touching the generator or reseeding. The generator output remains sacred (D-109); overlays are how the world changes after generation. This makes the overlay system the canonical content injection point for all post-generation content, whether player-driven, storyteller-driven, or DLC/mod-driven.
|
||||
- **Source:** Tyre architectural analysis, 2026-02-27.
|
||||
- **Raised by:** Tyre (comprehensive edge case mapping), lead (edge case prompt: deep mines, player bases, fortresses).
|
||||
- **Dissent:** None.
|
||||
- **Cross-reference:** D-110 (signed z-levels), D-111 (MobileChunk Idle for installations), D-108 (MobileChunk), D-096 (organic layout), D-100/D-109 (LocalOverlay)
|
||||
|
||||
---
|
||||
|
||||
*29 decisions. Last updated: 2026-02-27 (D-096 through D-109 added — Generator Architecture Workshop #562)*
|
||||
*32 decisions. Last updated: 2026-02-28 (D-108 amended Sprint 22 — Idle state as stationary installation primitive note added, D-111 cross-reference added)*
|
||||
|
||||
@@ -19,13 +19,15 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
- **Date:** 2026-02-09
|
||||
- **Decision:** Camera is locked to the character at all times. No panning. Optionally rotates to character facing direction (player option, later version).
|
||||
- **Rationale:** Pannable camera breaks the information model - you become a surveillance drone, not a character. Locked camera reinforces "you ARE this person." Rotation with facing direction restores directional audio mapping (binaural becomes viable again) and creates a natural vision cone (front = detailed, peripheral = reduced, behind = blind).
|
||||
- **Vision cone model:**
|
||||
- Forward: full LOS, full detail
|
||||
- Peripheral: reduced range, dimmer
|
||||
- Behind: fog / blind spot. You can be snuck up on.
|
||||
- **Vision cone model (physiological basis):**
|
||||
- Forward: 120° arc (60° half-angle) — binocular overlap zone where both eyes converge, providing stereoscopic depth and high acuity. Full LOS range, full detail. Server sends only Forward tiles.
|
||||
- Behind: 240° arc — not in the forward cone. Previously-explored tiles render with a light fog overlay (art and information preserved, just "not fresh"). The monologue system (D-016) bridges perceptual gaps when the character's senses fire ("Footsteps behind me").
|
||||
- Physiological reference: human binocular overlap ~120° (NCBI Visual Fields NBK220).
|
||||
- **Per-character cone configuration:** The 120° baseline is the unaugmented human floor. Bionic implants, perception modes (D-017), and equipment widen the cone. A back-of-head optical implant might expand to 240°+; an ANA-touched character might approach 360°. `VisionConeConfig` is per-entity — different characters have different awareness envelopes. This is gameplay parameter variation, not a protocol change (D-020).
|
||||
- **Reference:** Hotline Miami's camera made that game terrifying with the same principle.
|
||||
- **v0.1:** Locked camera, no rotation. Vision cone still works on fixed-north map. Rotation deferred as player option.
|
||||
- **Raised by:** Team Leader (Jeroen).
|
||||
- **Updated:** 2026-02-27 — cone angles grounded in human sensory physiology; per-character config documented.
|
||||
|
||||
### D-016: Internal monologue as core perception/atmosphere system
|
||||
- **Date:** 2026-02-09
|
||||
@@ -54,9 +56,11 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
- Higher: biononic thermal, enhanced spectrum, passive scanning
|
||||
- ANA-touched: pattern recognition across all feeds, predictive awareness
|
||||
- **Playstyle implications (Nigel):** Low-tech Guardian playthrough = survival horror (blind, relying on contacts and paranoia). Senator playthrough = information overload (cameras and tracking but drowning in data). Perception modes are playstyle selectors.
|
||||
- **Vision cone modification:** Perception modes and bionic equipment widen the vision cone beyond the unaugmented 120° baseline (D-015). Examples: back-of-head optical implant → 240°+ arc; thermal overlay → omnidirectional heat signatures through walls; ANA-touched awareness → near-360° pattern recognition. Each mode modifies `VisionConeConfig` per-entity. The fog shader renders whatever the server classifies — no client-side awareness of cone angles.
|
||||
- **v0.1:** Natural vision cone + basic audio indicators + insert minimap only. Additional modes are milestone features, each self-contained and modular.
|
||||
- **Engine implication:** Each perception mode is an observer query against the information boundary system ([D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline) principle 2). Engine doesn't distinguish between eyes/thermal/camera - all are "given this sensor, what state is visible?"
|
||||
- **Raised by:** Team Leader (Jeroen) proposed thermal and camera hacking. Full team developed into perception mode framework.
|
||||
- **Updated:** 2026-02-27 — vision cone modification by perception modes documented.
|
||||
|
||||
### D-018: Three-range sound model
|
||||
- **Date:** 2026-02-09
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Open Questions — Architecture
|
||||
|
||||
Technical foundation questions: engine, protocols, data structures, performance, save/load.
|
||||
|
||||
---
|
||||
|
||||
### Q-001: Game engine selection
|
||||
- **Status:** Resolved → [D-020](architecture.md#d-020-engine-and-architecture-selection--godot-client--rust-simulation-via-subprocessipc)
|
||||
|
||||
### Q-006: Multiplayer or single-player only?
|
||||
- **Status:** Resolved → [D-009](architecture.md#d-009-multiplayer---design-for-it-build-single-player-first)
|
||||
|
||||
### Q-009: Time system
|
||||
- **Status:** Resolved → [D-031](architecture.md#d-031-time-system--game-clock-and-day-phases)
|
||||
|
||||
### Q-018: Shadowcasting algorithm selection
|
||||
- **Status:** Resolved → [D-035](perception.md#d-035-symmetric-shadowcasting-albert-ford-selected-for-los-computation)
|
||||
- **Question:** Which line-of-sight algorithm should be used? Symmetric shadowcasting (Albert Ford) vs recursive shadowcasting. Both are proven but differ in symmetry properties (symmetric: if A sees B, then B sees A) and implementation complexity. Requires benchmarking at 150x150 map scale with 30 entities to validate performance within 100ms tick budget.
|
||||
- **Context:** D-011 mandates LOS shadowcasting for fog of perception. Architecture review identified this as unspecified (audit section 2.2). Critical for Sprint 2 perception pipeline.
|
||||
- **Assigned to:** Tyre, Dudley
|
||||
- **Source:** Architecture Review Audit 2026-02-11
|
||||
|
||||
### Q-019: Entity ID stability strategy
|
||||
- **Status:** Partially resolved → [D-041](architecture.md#d-041-knowledge-graph-data-model)
|
||||
- **Resolution:** Server-side: `StableEntityId` component + `EntityRegistry` resource provides bidirectional `StableId(u64) <-> Entity` mapping. StableId assigned once at entity spawn, never changes, persists across save/load. Knowledge graphs reference StableId, not bevy Entity. Client-side mapping (Godot StableId -> scene node lifecycle) remains open.
|
||||
- **Remaining:** Client-side entity lifecycle management, scene node mapping strategy.
|
||||
- **Date partially resolved:** 2026-02-11
|
||||
- **Assigned to:** Tyre, Dudley (client-side portion)
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop
|
||||
|
||||
### Q-020: Multi-entity collision resolution
|
||||
- **Status:** Open
|
||||
- **Question:** When two NPCs attempt to move to the same tile on the same tick, what is the resolution policy? Options: first-write-wins (deterministic with system ordering), both fail (conservative), priority-based (e.g., player > NPC, Active tier > Background tier).
|
||||
- **Context:** D-012 defines tile collision. WalkabilityMap exists (server/src/simulation/movement.rs) but handles single-entity validation. Architecture review identified multi-entity collision as unspecified.
|
||||
- **Assigned to:** Gestalt, Dudley
|
||||
- **Source:** Architecture Review Audit 2026-02-11
|
||||
|
||||
### Q-021: Tick budget overflow policy
|
||||
- **Status:** Open
|
||||
- **Question:** When a simulation tick exceeds the 100ms budget, what happens? Options: (1) slow down real-time and preserve determinism (tick completes fully before next), (2) skip ticks and break determinism, (3) cap work per tick and defer to next tick. Must align with D-010 principle 4 (deterministic simulation).
|
||||
- **Context:** D-026 defines 100ms tick budget for Active tier at 10 tps. Architecture review consensus recommendation proposes "slow real-time, don't skip ticks." Needs formal decision.
|
||||
- **Assigned to:** Tyre, Dudley
|
||||
- **Source:** Architecture Review Audit 2026-02-11
|
||||
|
||||
### Q-022: NPC pathfinding cache eviction
|
||||
- **Status:** Open
|
||||
- **Question:** With 80 Active-tier NPCs each caching ~3 pathfinding routes, the cache holds ~240 paths. What is the eviction policy? LRU? Time-based expiration? Fixed size per NPC? How are paths invalidated when walkability changes (doors lock, areas become restricted)?
|
||||
- **Context:** Architecture review identified pathfinding as MEDIUM gap (audit section 2.2). Cache management needs specification regardless of algorithm choice.
|
||||
- **Assigned to:** Tyre, Dudley
|
||||
- **Source:** Architecture Review Audit 2026-02-11
|
||||
|
||||
### Q-023: Debug visualization scope
|
||||
- **Status:** Open
|
||||
- **Question:** What information should the debug overlay display? Candidates: LOS rays, pathfinding waypoints, vision cones, information boundary tags (who knows what), tick timing breakdown, spatial partition grid cells. Dev-only tool, or accessible for mod development?
|
||||
- **Context:** Architecture review (Troblum) identifies debug visualization as missing operational infrastructure. Needed for debugging perception system, information boundaries, and performance issues.
|
||||
- **Assigned to:** Tyre, Stig
|
||||
- **Source:** Architecture Review Audit 2026-02-11
|
||||
|
||||
### Q-029: Save file format design
|
||||
- **Status:** Open
|
||||
- **Question:** What should the long-term save file format look like? Key considerations:
|
||||
1. **Versioning and migration:** How do saves survive across game versions? Schema evolution strategy (field additions, renames, removals). Should saves embed a version number and run migrations on load?
|
||||
2. **Compression:** Raw MessagePack vs compressed (zstd, lz4)? Tradeoff between save/load speed and file size. SaveStateV1 is already MessagePack — does that carry forward?
|
||||
3. **Integrity:** Checksums or signatures to detect corruption? CRC32 header?
|
||||
4. **Metadata header:** Should the file have a readable header (game version, save date, play time, character name) that the loading screen can read without deserializing the full save?
|
||||
5. **Determinism:** D-010 requires deterministic simulation. Can saves capture enough state to resume deterministically, or is approximate resume acceptable?
|
||||
6. **Modding:** Should the format be documented for mod authors? Does it need extension points?
|
||||
7. **Cloud sync:** Any considerations for Steam Cloud or similar? File size limits?
|
||||
- **Context:** Sprint 19 implements a quick-and-dirty save format (D-085 per-game directories, MessagePack serialization from SaveStateV1). This question tracks the thorough design pass for production quality.
|
||||
- **Assigned to:** Tyre, Dudley
|
||||
- **Source:** Team Leader directive (Sprint 19 planning)
|
||||
|
||||
### Q-030: Seed configuration schema
|
||||
- **Status:** Open
|
||||
- **Question:** What artifact records all randomizer decisions at game start? The wiki-review workshop proposed a `seed-state.yaml` capturing: world seed, character selection, pool draws (Tier 1 modules, FRIEND selection, contraband variant), template assignments, NPC trait rolls, triangle configurations, and entanglement pattern. Ticket #394 (seed configuration schema design) exists but the design is open.
|
||||
- **Assigned to:** Tyre, Gestalt
|
||||
- **Source:** Wiki Review Workshop + v0.1 Content Scoping Workshop
|
||||
|
||||
### Q-046: Departure schedule model — departure windows as generator output for docked vessels
|
||||
- **Status:** Resolved → D-108 (MobileChunk Specification)
|
||||
- **Resolution:** `scheduled_departure: Option<SimTick>` in `Docked` state is mandatory generator output. Vessels without departure schedules are an error state. The `Docked` struct must include `docked_since: SimTick` and `scheduled_departure: Option<SimTick>` — these fields must be added at implementation time (absent from Tyre's Round 4 canonical struct).
|
||||
- **Date resolved:** 2026-02-27
|
||||
- **Source:** Generator Architecture Workshop (#562)
|
||||
- **Assigned to:** Tyre + Miri
|
||||
|
||||
---
|
||||
|
||||
*12 questions (6 resolved, 1 partially resolved, 5 open). Last updated: 2026-02-28.*
|
||||
@@ -0,0 +1,178 @@
|
||||
# Open Questions — Content
|
||||
|
||||
Narrative, NPCs, dialogue, templates, setting, worldbuilding, and storyteller mechanics.
|
||||
|
||||
---
|
||||
|
||||
### Q-010: Storyteller AI design
|
||||
- **Status:** Not yet discussed
|
||||
- **Question:** How does the Rimworld-style storyteller work? What are the pacing rules? How much structural randomness vs dramatic randomness?
|
||||
- **Assigned to:** Gestalt, Nigel
|
||||
|
||||
### Q-012: Generation expansion method for dialogue
|
||||
- **Status:** Open
|
||||
- **Question:** How does the 4x generation expansion pass work? LLM-based, template-based, or rule-based? Affects how base lines are authored — LLM needs style-strong anchors; rules need substitution patterns.
|
||||
- **Assigned to:** Gestalt, Mellanie
|
||||
- **Source:** Content Gap Analysis Workshop (Mellanie R2)
|
||||
|
||||
### Q-013: Line previewer temporal progression
|
||||
- **Status:** Open
|
||||
- **Question:** How does the line previewer (#193) handle THE FRIEND's multi-visit contradiction arc? Needs sequence mode to simulate interaction progression over multiple encounters.
|
||||
- **Assigned to:** Gestalt, Dudley
|
||||
- **Source:** Content Gap Analysis Workshop (Mellanie R2)
|
||||
|
||||
### Q-015: Generation expansion for THE FRIEND content
|
||||
- **Status:** Open
|
||||
- **Question:** Does the 4x generation expansion apply to THE FRIEND's custom lines, or are all ~70-100 lines per FRIEND hand-authored? Recommendation: no expansion on FRIEND lines — risk of flattening hand-crafted voice.
|
||||
- **Assigned to:** Mellanie, Gestalt
|
||||
- **Source:** Content Gap Analysis Workshop (Ozzie R2, Gestalt R2)
|
||||
|
||||
### Q-017: Triangle pressure threshold
|
||||
- **Status:** Open
|
||||
- **Question:** Triangles need a numeric pressure value that increases based on events. When it crosses a threshold, `confrontation` situation activates. What events increase pressure? What's the threshold?
|
||||
- **Assigned to:** Gestalt, Paula
|
||||
- **Source:** Content Gap Analysis Workshop (Gestalt R2)
|
||||
|
||||
### Q-028: Collision-resistant line IDs for auto-generated NPCs
|
||||
- **Status:** Resolved → [D-084](content.md#d-084-dual-namespace-line-id-scheme--role-pool--instance-override)
|
||||
- **Resolution:** The collision problem is mostly already solved by the role-pool architecture: `dock-worker_d_###` lines are shared content for all instances of the role, not per-instance IDs. A true collision (two distinct authored lines sharing the same ID) cannot occur with one file per role. For the edge case of authored instance-specific content, a role-slug + zero-padded generation counter suffix produces `dock-worker-07_d_001`. Counter is seeded-deterministic. No schema change, no migration. Hand-authored NPCs unchanged.
|
||||
- **Closed by:** Gestalt (Sprint 18, #544). 2026-02-25.
|
||||
- **Ticket:** #544
|
||||
- **Assigned to:** Gestalt, Tyre
|
||||
- **Source:** Sprint 16 PR #59 review discussion (2026-02-23)
|
||||
|
||||
### Q-031: Combined content style guide
|
||||
- **Status:** Open
|
||||
- **Question:** Should the project have a single combined content style guide merging Paula's tier templates, Mellanie's voice conventions, Gestalt's mechanical constraints, and Miri's regional guide? The wiki-review workshop proposed this as a deliverable but it was never authored. What format, who owns it, and does it block content authoring?
|
||||
- **Assigned to:** Mellanie, Paula
|
||||
- **Source:** Wiki Review Workshop R2
|
||||
|
||||
### Q-033: Three-system NPC architecture
|
||||
- **Status:** Open
|
||||
- **Question:** Should NPCs be formally composed from 9 thematic patterns (FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY) x 6 functional motivations (HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC)? D-024 defines 10 axes + combat but predates this refined system. The wiki-review workshop produced a full composition matrix with drama ratings and forbidden combinations. Does this supersede D-024 or extend it?
|
||||
- **Assigned to:** Gestalt, Paula
|
||||
- **Source:** Wiki Review Workshop R4
|
||||
|
||||
### Q-040: Gate dual-use topology — freight and commuter on shared span gate infrastructure
|
||||
- **Status:** Resolved → D-093 (gate cluster zone spec), D-095 (span gate dual-use windows)
|
||||
- **Question:** System span gates serve both freight and commuter traffic (one gate per system). How does this work physically? Is it one gate aperture with scheduling (freight window vs. passenger window), or parallel lanes (separate apertures for freight and passenger flows)? What does the gate facility look like from the inside — a single large bay or divided infrastructure?
|
||||
- **Layout implication:** Affects the gate cluster spatial design in the Transit District — the gate cluster must accommodate both freight staging and passenger throughflow, possibly at different times of day.
|
||||
- **Assigned to:** Miri
|
||||
- **Source:** Station District Layout Workshop (#153), Round 2. Surfaced by lead correction to Miri's S-02 (commuter transit ≠ second external gate).
|
||||
- **Cross-reference:** D-036 (Sova setting), Q-036 (district skeleton as generator output)
|
||||
|
||||
### Q-041: Interstellar travel mechanics — horizon stations and gate architecture
|
||||
- **Status:** Resolved → D-095 (horizon stations: alien-built, 4–8 apertures, Oort-cloud, "The Ring"; sequential hop travel)
|
||||
- **Question:** A system needs MORE than one horizon gate for multi-hop connectivity (one gate allows only 1:1 connections). Lead proposal (Round 3): **Horizon stations** — orbital installations at Oort-cloud distance, partially or wholly understood ancient alien technology, self-maintaining (Mass Effect relay/Citadel analog). Each horizon station holds a FIXED number of active and inactive horizon gates. Some systems may only have one hop to an orbital customs station with no direct planet-side span gate access. Remaining questions: How many gates per horizon station? What determines which gates are active vs. inactive? Is the travel instantaneous or traversal-based? What is "The Ring" (the orbital horizon station) like as a physical space?
|
||||
- **Assigned to:** Miri
|
||||
- **Source:** Station District Layout Workshop (#153), Round 2. Lead correction in Round 3: single-gate-per-system model insufficient for multi-hop travel; horizon station model proposed.
|
||||
- **Cross-reference:** D-036 (Sova setting), Q-039 (gate topology generation), Q-040 (gate dual-use topology)
|
||||
|
||||
### Q-042: Intra-system transport networks — passenger vs. freight, vehicles and modes
|
||||
- **Status:** Partially resolved → D-095 (span gates at star/planetary level; horizon stations at Oort distance). Intra-system hab-to-hab transit remains open.
|
||||
- **Question:** How do people and goods move within a star system (between orbital stations, planetary surfaces, and other in-system facilities)? Are there two separate networks (passenger transport and freight transport) or one shared network? What are the vehicle types and transit modes? How does intra-system transit interact with the span gate at the system's hub station?
|
||||
- **Assigned to:** Miri
|
||||
- **Source:** Station District Layout Workshop (#153), Round 2. Flagged by lead as transport lore requiring formal tracking.
|
||||
- **Cross-reference:** D-036 (Sova setting), Q-043 (station internal transit)
|
||||
|
||||
### Q-043: Station internal transit — intra-station transport system between districts
|
||||
- **Status:** Resolved → D-095 (The Loop: 6-district tram, 4-minute Residential Core → Transit District; transit platform is bar-side encounter node)
|
||||
- **Question:** What is the intra-station transport system on Station Sova? How do workers commute between districts (e.g., Residential Core → Transit District)? Is it a train, tram, shuttle, or pressurised corridor? What is the travel time and frequency? Where does the transit stop sit within the Transit District — gate-cluster-adjacent (workers arrive near freight operations) or bar-side-adjacent (workers arrive near their social space)?
|
||||
- **Layout implication for #153:** The Transit District must include an internal transit stop. Its position within the district affects NPC traffic patterns and the district entry topology. This is the active T-03b question for Round 2/3 of the Station District Layout Workshop.
|
||||
- **Assigned to:** Miri
|
||||
- **Source:** Station District Layout Workshop (#153), Round 2. Arose from lead correction: commuter transit = internal station transit, not a second external gate.
|
||||
- **Cross-reference:** D-036 (Sova setting), Q-042 (intra-system transport networks), S-02 revision
|
||||
|
||||
### Q-044: Gate-train integration — do transport vehicles use gates directly or transfer on each side
|
||||
- **Status:** Resolved → D-093/D-095 (gates are pedestrian/cargo-only; passengers transfer via gate concourse → transition corridor → transit platform; no direct gate-to-tram connection)
|
||||
- **Question:** If trains or shuttles are the intra-system or intra-station transit mode, do they use the span gate directly (a train enters the gate and exits at the destination, carriages and all)? Or are the gates pedestrian/cargo-only, requiring passengers and freight to transfer to separate transport on each side? What does this imply for gate terminal design — does it need platforms, or just processing space?
|
||||
- **Assigned to:** Miri
|
||||
- **Source:** Station District Layout Workshop (#153), Round 2. Flagged by lead as transport lore requiring formal tracking.
|
||||
- **Cross-reference:** Q-040 (gate dual-use topology), Q-043 (station internal transit)
|
||||
|
||||
### Q-045: Axis 11 — Network Footprint NPC tag
|
||||
- **Status:** Open
|
||||
- **Priority:** High
|
||||
- **Question:** Should the NPC model (D-024, 10 axes) gain an 11th axis: `network_footprint: Option<NetworkFootprintTag>` for NPCs who are locally insignificant in appearance but carry network-significant information or are relevant to external actors? Default `None` for procedural NPCs. Explicitly set for authored scenario NPCs. This would enable the storyteller to identify locally-invisible but network-critical nodes without breaking the NPC's mundane character.
|
||||
- **Context:** Raised during Generator Architecture Workshop (#562). The Ysabel Vorn litmus test (4.5/5 playstyle hooks, Backwater/Moderate setting) demonstrated that locally-insignificant NPCs can be key network nodes. Without this field, the generator has no mechanism to flag them to the storyteller.
|
||||
- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §NPC Model.
|
||||
- **Assigned to:** Miri
|
||||
|
||||
### Q-047: Mobile environment social arc — structural representation of journey timeline
|
||||
- **Status:** Open
|
||||
- **Priority:** Medium
|
||||
- **Question:** How is the social arc of a mobile environment journey (BoundedLinear / BoundedMobile) represented structurally? The journey has a beginning (boarding, strangers), middle (established dynamic), and end (departure, relationship crystallized). What game structures capture this timeline and enable the storyteller to intervene? Does `TransitSocialModifier` need a journey-phase field?
|
||||
- **Context:** Ozzie's player experience requirement from Generator Architecture Workshop (#562): "the journey must have a social arc — not just social presence." The stage+cast framing is correct; the formal structural representation is unspecified.
|
||||
- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §Open Questions.
|
||||
- **Assigned to:** Miri + Gestalt
|
||||
|
||||
### Q-048: DramaDensity enum naming — 3-level vs 5-level
|
||||
- **Status:** Open
|
||||
- **Priority:** Low
|
||||
- **Question:** The Round 4 struct uses `Quiescent / Active / Intense` (3 levels). Round 3 proposed `Zero / Low / Medium / High / Flashpoint` (5 levels). Which should be canonical? Nigel's position: `Flashpoint` should be preserved as a distinct peak value — it is the storyteller's maximum-pressure instrument and should not collapse into `Intense`. If 3 levels are chosen for implementation simplicity, `Flashpoint` should still be the distinct peak name, not `Intense`.
|
||||
- **Context:** ComplexityTier → DramaDensity ceiling (established): Full → any intensity; Moderate → Active max; Minimal → Quiescent max; Empty → Zero only. Naming must be consistent with these ceiling values.
|
||||
- **Source:** Generator Architecture Workshop (#562), Rounds 3–4. `docs/workshops/generator-architecture/workshop-outcomes.md` §Open Questions.
|
||||
- **Assigned to:** Tyre + Gestalt
|
||||
|
||||
### Q-049: ObjectTag vocabulary co-maintenance — Miri and Araminta shared dependency
|
||||
- **Status:** Open
|
||||
- **Priority:** Medium
|
||||
- **Question:** The `ObjectTag` vocabulary must be co-maintained between Miri's `HeritageGrammarOverlay` (cultural grammar, Rust struct) and Araminta's asset categorization (visual expression, TOML files). What is the governance model? Who owns the canonical tag list? How are additions and deprecations coordinated? Does the vocabulary live in the Rust struct definition or in a shared data file?
|
||||
- **Context:** If the vocabulary diverges, the generator will reference tags that don't exist in asset categories, or assets will be authored that the grammar never references. This is a silent correctness failure.
|
||||
- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-9.
|
||||
- **Assigned to:** Miri + Araminta
|
||||
|
||||
### Q-050: Assassination difficulty synthesis — formal spec combining stored baseline with on-demand computation
|
||||
- **Status:** Open
|
||||
- **Priority:** Medium
|
||||
- **Question:** Formal specification needed for the synthesis combining stored cultural baseline (`DerivedDistrictAnalysis` on Phase 1 skeleton) with on-demand runtime computation for player-facing assessment. Key constraint from Miri: **on-demand computation is display-only** — all game logic (tactical triangle instantiation, guarantee audit) uses the Phase 1 `DerivedDistrictAnalysis` value. The on-demand computation is subordinate to the stored baseline, not a replacement.
|
||||
- **Context:** Minor tension between Gestalt's "computed entirely on demand" position and Miri's "stored cultural baseline" position. Synthesis accepted by both participants in Generator Architecture Workshop (#562); formal spec needed for implementation.
|
||||
- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §Open Questions.
|
||||
- **Assigned to:** Gestalt + Miri
|
||||
|
||||
### Q-052: Storyteller hint delivery — parallel diegetic channels when player does not act
|
||||
- **Status:** Open (Sprint 22 analysis complete, awaiting implementation scoping)
|
||||
- **Priority:** High
|
||||
- **Question:** When a TriangleActivated event fires and the player does not investigate, what mechanisms create conditions for discovery? How do these scale from v0.1 (1-2 triangles) to the full game (300 worlds, 10+ simultaneous triangles, 8 archetypes)?
|
||||
- **Core design principle:** "The storyteller is not delivering messages — it is creating conditions under which the player's existing relationships, existing investments, and existing world naturally produce signal." Player inaction is a valid outcome, not an error state. Consequences happen; the world moves on.
|
||||
- **Channel inventory (ranked by deniability — least nudge-like first):**
|
||||
1. **Behavioral tell escalation** — activated triangle NPCs exhibit more pronounced tells (D-024 axis 9). Always active, proximity-gated. Feels like the world being alive. Scales infinitely, zero authoring per triangle.
|
||||
2. **Environmental change** — something physically different at the triangle location (locked door, moved cargo, light on at wrong hour). Archetype-agnostic: engineer reads the system fault, diplomat reads the guard rotation, merchant reads the sealed bay. Infinite scale, zero authoring per triangle.
|
||||
3. **Ambient sound cue** — audible change in the triangle zone (hushed conversation, footsteps at unusual time, a door that was open now shut). Uses D-018 three-range system. Proximity-gated.
|
||||
4. **Overheard NPC conversation** — secondary NPCs gossip about the activated NPC's changed behavior. Fires when two NPCs share a scene in a social site. Scales with the social simulation, not with authoring. Constraint: the NPC speaks from their own concerns ("He owes me for last week's haul and now he's not answering"), never from narrative convenience ("I think something is wrong with Kael").
|
||||
5. **Unprompted NPC remark** — a non-job-giver NPC proactively comments when the player approaches. Uses D-028 Layer 4 unprompted disclosure with `situation: [triangle_activated]`.
|
||||
6. **FRIEND message** — the FRIEND NPC (D-034) reaches out. Structurally unique: when the FRIEND IS the triangle anchor (smuggler/Kael), the message is simultaneously the nudge and the tell — its timing, phrasing, and whether it sounds normal are the investigative data. Reserve for the triangle the FRIEND is directly connected to; degrades on overuse (one FRIEND per character = hard limit).
|
||||
7. **Internal monologue** — player character's inner voice notices something. Proximity-gated to triangle NPCs exhibiting tells. Character-specific voice (D-032, D-090).
|
||||
8. **Job-giver app message** — institutional backstop of last resort. Fires only if no other channel has produced player engagement. One per session maximum, for the single highest-engagement triangle only. Job-giver per archetype: smuggler = handler ("Dock 7, pickup, ask for Kael"), detective = Commission superior ("Welfare check, dock-level, concern logged by port authority"), engineer = fault log, diplomat = peer request, merchant = market signal. Three sender types: named NPC (personal), institution (semi-personal), automated system (impersonal).
|
||||
- **Delivery model — parallel channels, not linear funnel:**
|
||||
- All channels activate on TriangleActivated. The player encounters whichever their playstyle naturally intersects first.
|
||||
- Channels have capacity, not triangles: monologue holds 1 thread (highest engagement wins), gossip holds 2-3, tells are unlimited. Lower-engagement triangles naturally get quieter channels — no starvation.
|
||||
- App message has a long fuse; only delivers if no other channel has been "hit" (storyteller detects player engagement via any channel). Prevents the pattern from being predictable across playthroughs.
|
||||
- Diegetic test for all channels: could you explain the output without reference to the activated triangle? If yes, diegetic. If no, quest marker.
|
||||
- **Scaling properties (v0.1 → full game):**
|
||||
- Channel priority inverts at scale. v0.1: authored channels (monologue, job-giver) dominate. Full game: universal channels (environmental, overheard, tells) are the foundation because they scale with the simulation, not with authoring.
|
||||
- Job-giver coherence breaks if overused — handler sending player to 3 activated locations in one session becomes transparent. Backstop for highest-engagement triangle only.
|
||||
- Hint adaptation: storyteller tracks `PlayerHintEngagement` — how often the player engages after each channel tier. Extends fuses and reduces probabilities as player demonstrates skill. Hours 1-5: full hints. Hours 15+: player reads the world by behavioral tells alone. "Not harder combat — a quieter, more trusting world." Resets per new playthrough.
|
||||
- ~15-20 hour meta-awareness cap: beyond that, the game likely needs a complementary active system (case board, network map) the player reads rather than passively receives. Separate design problem, out of scope here.
|
||||
- **Signal pollution gating:**
|
||||
- Monologue: only when player is within proximity of a triangle NPC exhibiting tells.
|
||||
- Gossip: only when the player initiates conversation with a connected secondary NPC. Passive, not pushed.
|
||||
- FRIEND message: only if the FRIEND has a plausible in-world reason to reach out at this moment (established relationship cadence).
|
||||
- Job-giver: once per session, backstop only.
|
||||
- Environmental/tells: always active, no gating needed (they ARE the world).
|
||||
- **Implementation implications:**
|
||||
- Storyteller needs a `ChannelSet` per activated triangle tracking which channels have fired and whether the player has engaged.
|
||||
- Post-activation state machine: `Activated → [channels live] → Resolved (player engages) | Consequences (timeout) | Expired`.
|
||||
- NPC behavioral state changes need to register as conversation topics for social-network propagation (simulation feature, not content feature).
|
||||
- App message content uses D-028 tagged line pools with new `situation: [app_message]` tag and sender-type sub-tag. Rendered in neural insert UI as message thread, not monologue overlay.
|
||||
- **Content pipeline:**
|
||||
- Universal channels (environmental, overheard, tells): zero per-triangle authoring. Build once as simulation features.
|
||||
- Authored channels (monologue, FRIEND, job-giver): per-character/per-archetype investment. Layer on top as richness.
|
||||
- Job-giver treated as authored feature shipping archetype by archetype, not universal system shipping once.
|
||||
- **Context:** Raised during Sprint 22 storyteller scoping (#162). Analysis by Gestalt (systems) and Paula (narrative) across two rounds.
|
||||
- **Cross-reference:** D-023 (storyteller activation), D-016 (monologue system), D-018 (three-range hearing), D-024 (NPC axes — tell system), D-028 (dialogue architecture), D-032 (separate monologue pools), D-034 (THE FRIEND), D-090 (character voice), #162 (storyteller module activation)
|
||||
- **Assigned to:** Gestalt, Paula
|
||||
|
||||
---
|
||||
|
||||
*19 questions (5 resolved, 1 partially resolved, 13 open). Last updated: 2026-02-28.*
|
||||
@@ -0,0 +1,104 @@
|
||||
# Open Questions — Perception
|
||||
|
||||
Player observation, information systems, UI surfaces, knowledge mechanics, and insert workspace.
|
||||
|
||||
---
|
||||
|
||||
### Q-003: Art direction / presentation style
|
||||
- **Status:** Further resolved for v0.1 via Content Gap Analysis Workshop. Araminta's v0.1 Visual Grammar proposal covers: entity color palette (D-033), zone-specific color palettes (3 zones with hex values), fog rendering (4 states), monologue presentation spec, insert dual-character variants. Long-term art direction beyond v0.1 remains open.
|
||||
- **Remaining:** Long-term art direction, sprite replacement timeline, full visual identity.
|
||||
- **Assigned to:** Araminta (lead)
|
||||
|
||||
### Q-014: Audio timing with monologue chime
|
||||
- **Status:** Resolved → [D-067](perception.md#d-067-recognition-chime-fires-at-onset-of-cognitive-delay)
|
||||
- **Resolution:** Chime fires at ONSET of cognitive delay, not completion. 300-400ms duration, overlapping delay start. Chime is "unresolved" — opens a question, doesn't answer one. Sequence: hear/sense → chime plays → 0.6s delay begins → monologue text during delay → blob transitions to D-033 color → recognition complete.
|
||||
- **Date resolved:** 2026-02-16
|
||||
|
||||
### Q-016: Knowledge hierarchy for monologue prerequisites
|
||||
- **Status:** Resolved → [D-041](architecture.md#d-041-knowledge-graph-data-model)
|
||||
- **Resolution:** 4-level hierarchy: `Suspects < KnowsOf < KnowsDetails < Direct`. Suspects = "something's off", gates initial investigation and vague monologue. KnowsOf = "X is involved in Y", gates topic-specific dialogue and peer-tier access. KnowsDetails = actionable detail, gates confrontation and secret-tier dialogue. Direct = currently in LOS, provides live position data. Maps to D-028 access tiers and D-035 prerequisite tags.
|
||||
- **Date resolved:** 2026-02-11
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop
|
||||
|
||||
### Q-024: Gossip propagation timing
|
||||
- **Status:** Resolved → [D-080](perception.md#d-080-npc-to-npc-knowledge-propagation)
|
||||
- **Resolution:** Knowledge transfer occurs via a separate `transfer_npc_knowledge` Bevy system running `after(run_npc_conversations)`. Transfer fires once per conversation at conversation start (immediate during conversation, not queued). Rate: 1–3 facts drawn by recency. Trust-tier gated. See D-080 for full specification.
|
||||
- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — unanimous. 2026-02-24.
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt Round 1); resolved in Knowledge Flow & NPC Information Boundaries Workshop Round 2.
|
||||
|
||||
### Q-025: Knowledge graph cap and eviction strategy
|
||||
- **Status:** Resolved — no cap or eviction needed for v0.1/v0.2. Re-evaluation trigger: Active NPC count > 200 OR KG memory exceeds 50 MB.
|
||||
- **Question:** At what point does an NPC's knowledge graph need entry eviction? What is the eviction policy?
|
||||
- **Resolution (2026-02-24, confirmed by Knowledge Flow workshop):** Current analysis: ~14 KB per Active NPC KG (50 entities + 20 facts, D-041 budget). 80 Active NPCs = ~1.1 MB. 2,000 Background NPCs at 10 entries = ~5 MB. Total ~6 MB. With gossip propagation shipping in Sprint 17 (D-080, 1–3 facts per conversation): estimated ~12 MB peak at current NPC counts. Neither re-evaluation condition expected before v0.3. The existing decay system (`decay_knowledge` in `knowledge/events.rs`) downgrades confidence and marks entries Stale but does not remove them — correct behavior (preserves "I used to know X" for narrative). If eviction becomes necessary, simplest policy: on each decay pass, if entities.len() > MAX_ENTITIES, remove Stale entries with lowest last_updated_tick. BTreeMap makes this O(N).
|
||||
- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — Tyre, Gestalt, Dudley confirmed; Paula non-objection noted. 2026-02-24.
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop (Dudley Round 1, section 8.3). Architecture audit 2026-02-23. Workshop confirmation 2026-02-24.
|
||||
|
||||
### Q-026: Contradiction detection algorithm
|
||||
- **Status:** Resolved → [D-083](perception.md#d-083-contradiction-detection-pipeline)
|
||||
- **Resolution:** Event-driven detection at KG write time in `observe_entity()`, using `ContradictionClaim` struct. Location contradiction is automatic (Sprint 17): position comparison + time window (CONTRADICTION_WINDOW_TICKS = 600). Attribute and fact contradiction are content-authored (Sprint 18). Both ToldBy and DirectObservation entries receive Contradicted state (epistemic neutrality). `ContradictionDetected` event → monologue with resolved display names → relationship shift → AnomalyMarker via existing pipeline.
|
||||
- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — unanimous on architecture. 2026-02-24.
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt/Paula Round 1); resolved in Knowledge Flow & NPC Information Boundaries Workshop Round 2.
|
||||
|
||||
### Q-051: Speech bubble indicator over speaking NPCs
|
||||
- **Status:** Open
|
||||
- **Priority:** Low
|
||||
- **Question:** Add a generic speech bubble icon above the head of any NPC that is currently speaking in a conversation. Consider reusing the per-character color coding from the dialogue log so the player can easily map speech bubbles to dialogue entries.
|
||||
- **Context:** When multiple NPCs are nearby and the dialogue log is scrolling, it can be hard to tell who is speaking. A floating speech bubble icon (not the text itself — just the indicator) over the speaker's sprite would anchor dialogue to world position. Color-coding to match the dialogue log's per-character colors reinforces the mapping.
|
||||
- **Assigned to:** Stig + Araminta
|
||||
|
||||
### Q-053: Insert workspace boards — design philosophy and information architecture
|
||||
- **Status:** Open (Sprint 22 analysis complete)
|
||||
- **Priority:** High
|
||||
- **Question:** How do boards function as the player's primary active information surface, and what design principles govern their behaviour across archetypes, game phases, and concurrent use?
|
||||
- **Core design principle:** Boards are a **general-purpose communication layer** to the player, not a mechanic in themselves. The renderer is agnostic — it takes structured data and draws it. What creates a board, what populates it, and what its lifecycle is are decisions owned by the upstream system (quest, journal, navigation, business management, faction tracking, etc.), not the renderer.
|
||||
- **Design principles established:**
|
||||
1. **Epistemology map, not truth map.** "The board maps the player's epistemology, not the game's truth." Nodes appear as the player encounters them. Connections are always the player's work. The board never reveals information the player hasn't acquired through gameplay.
|
||||
2. **Passive nodes, active edges.** Things appear automatically when observed/learned; relationships between them are the player's inference. This makes the board a thinking tool, not a checklist.
|
||||
3. **Multiple concurrent boards.** A character can be on an investigation while running a business while tracking a social network. Boards attach to whatever upstream system creates them — a quest, a business, a journal category, a transit network. The workspace presents them as tabs or equivalent navigation.
|
||||
4. **Archetype-specific readings.** Same diagram data, different professional questions. Detective reads evidence chains, smuggler reads trust networks, engineer reads system diagrams, diplomat reads faction leverage, merchant reads supply lines. The rendering primitive is identical; the upstream system determines what nodes and edges mean.
|
||||
5. **Cross-board convergence as discovery.** When the same node appears on multiple boards, that IS the discovery moment. Consistent visual identity (same icon, same colour) lets player recognition do the work — no automatic highlighting, no "this person appears on another board!" popup. The player connects the dots.
|
||||
6. **Monologue as diagram interaction surface.** When the player focuses a board node, the monologue system can fire character-specific commentary. "He was at both meetings" is monologue, not board UI. This connects the active information system (Q-053) to the passive hint system (Q-052).
|
||||
7. **Board as relationship with an information source.** Every board has a creator — the upstream system that owns the data and pushed it. The creator's framing shapes the initial state: initial nodes are their view of the situation, gaps are what they didn't know, chose not to include, or deliberately withheld. The gap between a board's initial state and its final state is the story the board tells.
|
||||
8. **Confidence signal as information asymmetry.** Nodes need a confidence state: initial nodes from the creator are unconfirmed (faint); player-observed nodes are confirmed (solid). The distinction between "what you were told" and "what you know" is the information asymmetry mechanic expressed at the board level.
|
||||
9. **Boards as entitlement map.** The boards in a player's workspace are a map of their information entitlements — which systems trust them with data, which factions include them in their information flows, which institutions grant them access.
|
||||
- **Transit maps — core navigation mechanic:**
|
||||
- Transit maps are boards whose upstream system is the navigation/transport layer, not the political geography layer. They respond to political geography (route closures, faction control) but their primary function is **how the player gets around**.
|
||||
- Implementation: Network renderer with position-constrained layout mode (nodes pinned to world coordinates). Same rendering primitive as investigation boards (free layout mode), different layout constraint.
|
||||
- Transit maps exist from day one as a live, simulation-fed surface — not a static reference image.
|
||||
- **Archetype overlays are incidental discovery.** The smuggler's inspection-schedule annotation and the detective's jurisdiction boundary are visible through ordinary navigation, not special investigation mode. The player opens the map to travel and notices the secondary reading. Archetype depth surfaces through constant ordinary use. Design constraint: overlays must be subtle enough not to clutter the primary navigation function.
|
||||
- **Highest-frequency convergence surface.** Because the transit map is opened constantly, it is the most likely place for board nodes and navigation context to appear together without the player looking for it. A location that is a node on any active board shows its consistent visual identity on the transit map — same glyph, same colour. Player recognition does the work.
|
||||
- **Change over time is the political layer.** Because the map is used constantly, changes to it are noticed: a gate that was open is now restricted, an inspection checkpoint appeared on a familiar route. Infrastructure changes accumulate into a picture of what is shifting in the world — the conspiracy is visible in the map before it is visible in any individual NPC's behaviour.
|
||||
- **Scaling properties:**
|
||||
- v0.1: one or two board types (investigation + transit map) validate the rendering primitive and workspace navigation.
|
||||
- Full game: boards proliferate naturally as upstream systems ship — business dashboards, faction maps, crew manifests, engineering schematics, social network visualisations, reference material (field guides, legal codes).
|
||||
- Board count is not a design problem — it's a UX problem (workspace navigation, tab management, search/filter). The primitive scales inherently.
|
||||
- **Context:** Raised during Sprint 22 planning. Initial framing included d2 syntax as in-game format; lead directed that d2 remains a dev tool only and is not involved in the in-game system. Analysis by Gestalt (systems) and Paula (narrative) across three rounds with multiple lead corrections.
|
||||
- **Cross-reference:** Q-054 (rendering primitive data contract — the technical "how"), Q-052 (storyteller hint delivery — boards as the complementary active system), D-056/D-057 (insert diegetic conventions)
|
||||
- **Assigned to:** Gestalt, Paula
|
||||
|
||||
### Q-054: Insert workspace board — rendering primitive and data contract
|
||||
- **Status:** Open
|
||||
- **Priority:** High
|
||||
- **Question:** What is the board rendering primitive and its data contract? A board is a **general-purpose structured information surface** rendered inside the insert workspace. The renderer is agnostic to data source — it takes structured data and draws it. Upstream systems (quests, journal, navigation, faction tracking, reference material, business management, or anything with structured data worth visualizing) are responsible for creating boards, populating them, and managing their lifecycle. The renderer does not know or care why a board exists.
|
||||
- **Renderer architecture (Sprint 22 analysis):**
|
||||
- The renderer is a **stateless display service**. It takes `DiagramData` and draws it. It has no opinion on why a board exists, what created it, or what the data means.
|
||||
- Data contract: `DiagramData { id: DiagramId, diagram_type: Network | Flow | Table | Spatial, interaction_schema: InteractionSchema, annotations: Vec<PlayerAnnotation> }` plus type-specific content (nodes/edges, rows/columns, layers).
|
||||
- The renderer fires interaction events when the player clicks/hovers. Upstream systems handle those events.
|
||||
- Player annotations: renderer fires an annotation event upstream → server stores it → next `DiagramData` push includes the annotation. The renderer never holds its own copy of annotations or board state. Stateless rendering keeps save/load trivial.
|
||||
- The server includes the current `DiagramWorkspace` state in the `ObserverSnapshot`. The workspace is a list of `DiagramData` instances the player currently has access to.
|
||||
- Four rendering modes (Network, Flow, Table, Spatial). Two layout strategies for Network (free and position-constrained). A thin interaction event layer. That is the entire scope of the rendering infrastructure.
|
||||
- **Open design questions:**
|
||||
1. **Data contract details:** What node and edge types must the v0.1 contract support? Minimum viable: typed nodes + typed edges + optional metadata (label, confidence, timestamp).
|
||||
2. **Rendering vocabulary:** What visual primitives does the renderer expose — node shapes, edge styles, grouping/clustering, highlight states? What is explicitly out of scope for v0.1?
|
||||
3. **Lifecycle API:** What interface does an upstream system use to create, update, and close a board? Is this an ECS component, an IPC message, a client-side data structure, or some combination?
|
||||
4. **Player agency:** Is the board a read-only surface (upstream system writes, player reads) or can the player annotate — add notes, draw edges, pin nodes? If player writes are allowed, who owns that state? (Sprint 22 analysis: server owns annotation state; renderer is stateless.)
|
||||
5. **Insert integration:** How does the board surface within the insert UI — as a spoke, a workspace tab, a contextual overlay? How does the player navigate between multiple open boards?
|
||||
6. **v0.1 scope:** What is the minimum board implementation that validates the primitive — one upstream consumer, one node type, one edge type — without committing to a full vocabulary prematurely?
|
||||
- **Context:** Q-052 §scaling note flagged "a complementary active system (case board, network map) the player reads rather than passively receives" as out of scope for the hint delivery question. This is that system. Lead direction (Sprint 22): the rendering infrastructure is agnostic to data source; what attaches to a board, what populates it, and what its lifecycle is are decisions owned by the upstream system, not the renderer.
|
||||
- **Source:** Team Lead direction, Sprint 22 planning, 2026-02-28.
|
||||
- **Assigned to:** Gestalt, Paula
|
||||
- **Cross-reference:** Q-053 (board design philosophy — the "what" and "why"), Q-052 (storyteller hint delivery — boards noted as separate design problem), D-056/D-057 (insert diegetic conventions), D-023 (storyteller — one natural upstream consumer), D-024 (NPC axes — node content candidate)
|
||||
|
||||
---
|
||||
|
||||
*9 questions (5 resolved, 1 partially resolved, 3 open). Last updated: 2026-02-28.*
|
||||
@@ -0,0 +1,91 @@
|
||||
# Open Questions — Scope
|
||||
|
||||
Game concept, prototype boundaries, production pipeline, and feature decisions.
|
||||
|
||||
---
|
||||
|
||||
### Q-002: Scope of v0.1 playable prototype
|
||||
- **Status:** Map spec resolved ([D-014](scope.md#d-014-v01-map-specification)). Remaining: mechanics, characters, interactions for minimum playable build.
|
||||
- **Assigned to:** Full team
|
||||
|
||||
### Q-004: One campaign spanning all eras or separate era scenarios?
|
||||
- **Status:** Not yet discussed
|
||||
- **Context:** Gore raised that Commonwealth Era and Void Era play very differently. Prototype focuses on pre-Starflyer War era.
|
||||
- **Assigned to:** Gore, Miri to lead discussion
|
||||
|
||||
### Q-005: Scale for prototype - locations, characters, factions
|
||||
- **Status:** Partially scoped
|
||||
- **Early signal:** Institute/Armstrong City hub, ~10-20 characters, Guardians + institutional + political factions
|
||||
- **Assigned to:** Gestalt, Tyre, Miri
|
||||
|
||||
### Q-007: Target platform(s)
|
||||
- **Status:** Not yet discussed
|
||||
- **Context:** Team Leader has Linux background (Fedora). Cross-platform considerations?
|
||||
- **Assigned to:** Tyre
|
||||
|
||||
### Q-008: Licensing / distribution model
|
||||
- **Status:** Not yet discussed
|
||||
- **Question:** Open source? Free? Commercial? This affects engine choice and asset decisions.
|
||||
- **Assigned to:** Team Leader
|
||||
|
||||
### Q-011: Character selection and playable characters
|
||||
- **Status:** Not yet discussed
|
||||
- **Question:** Which characters are playable in the prototype? How different are their starting positions? Can you play canon characters or only original ones?
|
||||
- **Assigned to:** Miri, Paula
|
||||
|
||||
### Q-027: Fast-travel system design
|
||||
- **Status:** Open
|
||||
- **Question:** How does inter-system travel work in production gameplay? The current hub teleport (Home key, #501) is scoped as Gauntlet-only dev tool. Production travel must be diegetic and respect asymmetric information. Proposed flow: player goes to local gate → warps to system gate → interacts with target menu → jumps to destination system gate. Key constraints:
|
||||
1. **Region gating:** fast-travel only available from safe or fast-travel-enabled regions. If you rented transport to reach a remote location (e.g. mountain colony), you must return the transport to civilization first — this can be a skip-travel interaction but must happen in-world.
|
||||
2. **Asymmetric information:** NPCs observe arrivals and departures. Travel choices leak information (who saw you leave, who sees you arrive, what transport was used).
|
||||
3. **Home key in production:** at most, Home could prompt "Do you want to fast-travel to the system hub?" if in a safe/enabled region — never instant teleport.
|
||||
4. **Transport types:** walking, rented vehicle, public transit, gate network — each with different information exposure profiles.
|
||||
- **Context:** #501 implemented instant Home key teleport gated behind `gauntlet_mode`. Re-scoped to Gauntlet-only after design review. Production fast-travel needs separate design and implementation.
|
||||
- **Assigned to:** Gestalt, Paula, Tyre
|
||||
- **Source:** Sprint 10 PR review discussion (2026-02-19)
|
||||
|
||||
### Q-032: Cultural ingredients menu
|
||||
- **Status:** Open
|
||||
- **Question:** Should world generation use a 6-category cultural ingredients menu (Heritage Roots, Settlement Motivation, Economic Function, Philosophical Alignment, Corporate/Faction Presence, Drift Stage) where each culture is composed by selecting from ingredient lists? The lead approved the "ingredients menu" model over fixed cultural taxonomies. Full specification needed: category definitions, ingredient lists per category, composition rules, absence-as-signal mechanics.
|
||||
- **Assigned to:** Miri, Gestalt
|
||||
- **Source:** Wiki Review Workshop R4, lead interview
|
||||
|
||||
### Q-034: PC archetypes
|
||||
- **Status:** Open
|
||||
- **Question:** Should the full game support 8 fluid PC archetypes (Smuggler, Detective, Engineer, Diplomat, Medic, Scholar, Soldier, Merchant) with transition mechanics where archetype shifts during play based on player behavior? The lead approved 8 archetypes with fluid transitions as a game mechanic. v0.1 ships smuggler + detective only (D-027). Full archetype spec, transition triggers, and "vulnerable window" mechanics are undesigned. NOTE: The character-creation-game-setup workshop (Q-011) will address this — coordinate.
|
||||
- **Assigned to:** Nigel, Gestalt
|
||||
- **Source:** Wiki Review Workshop R4, lead interview
|
||||
|
||||
### Q-035: Sacred/Profane/Middle Kingdom framework
|
||||
- **Status:** Open
|
||||
- **Question:** Should all game systems map to a Sacred/Profane/Middle Kingdom architectural framework? The lead approved this model where Sacred = what the system protects, Profane = what threatens it, Middle Kingdom = where the player navigates. The wiki-review workshop produced a full mapping table covering information, social, economic, spatial, temporal, and narrative systems. Needs formal specification and validation against current architecture.
|
||||
- **Assigned to:** Gore, Gestalt
|
||||
- **Source:** Wiki Review Workshop R4, lead interview
|
||||
|
||||
### Q-036: District skeleton as generator output
|
||||
- **Status:** Open
|
||||
- **Question:** For the 300-world model, should the district skeleton (social sites, NPC slots, triangle templates, economic function, access topology) be the atomic output unit of the world generator? D-025 defines social sites as the atomic template unit for hand-authoring. The generator model reframes the district as a composed output from ingredient inputs. How does this interact with D-025?
|
||||
- **Assigned to:** Tyre, Gestalt
|
||||
- **Source:** Wiki Review Workshop R4
|
||||
|
||||
### Q-037: Generator development pipeline
|
||||
- **Status:** Open
|
||||
- **Question:** Should content production follow a 6-phase generator pipeline (Ingredient Authoring, Template Authoring, Generator Development, Validation Development, Generation + Review, Hand-Elevation)? The wiki-review workshop proposed this as the production model for 300 worlds. SI mapped a release path (v0.1 hand-authored, v0.2-0.5 template expansion, v0.6-0.10 generator development, pre-v1.0 validation). Needs scope assessment and sprint planning integration.
|
||||
- **Assigned to:** SI, Tyre
|
||||
- **Source:** Wiki Review Workshop R4
|
||||
|
||||
### Q-038: Authored content estimate at 300-world scale
|
||||
- **Status:** Open
|
||||
- **Question:** What is the irreducible authored content volume for 300 worlds? The wiki-review workshop estimated ~1,600-2,800 hours of hand-authoring for generator inputs (ingredient definitions, template specifications, validation rules, hand-elevation passes). How does this compare to the 20-district hand-authoring model it replaced? Is this estimate still valid given subsequent architectural decisions?
|
||||
- **Assigned to:** Mellanie, SI
|
||||
- **Source:** Wiki Review Workshop R4
|
||||
|
||||
### Q-039: Gate topology generation
|
||||
- **Status:** Open
|
||||
- **Question:** How should the world generator produce gate (wormhole) network topology for 300 worlds? The wiki-review workshop proposed: gate connectivity = Sacred (what connects), which worlds connect = Profane (what separates), accessible world count = Middle Kingdom (where the player navigates). Small-world network properties, hub-and-spoke vs mesh topology, and Sacred/Profane constraints on gate placement are all unresolved. D-012 covers chunk-based map architecture but predates the 300-world model.
|
||||
- **Assigned to:** Tyre, Nigel
|
||||
- **Source:** Wiki Review Workshop R4
|
||||
|
||||
---
|
||||
|
||||
*14 questions (0 resolved, 1 partially resolved, 13 open). Last updated: 2026-02-28.*
|
||||
@@ -1,333 +1,36 @@
|
||||
# Open Questions
|
||||
|
||||
Tracked questions awaiting discussion or resolution.
|
||||
Tracked questions awaiting discussion or resolution. Split by domain, mirroring the D-record structure.
|
||||
|
||||
---
|
||||
## Domain Files
|
||||
|
||||
### Q-001: Game engine selection
|
||||
- **Status:** Resolved → [D-020](architecture.md#d-020-engine-and-architecture-selection--godot-client--rust-simulation-via-subprocessipc)
|
||||
| File | Domain | Questions |
|
||||
|------|--------|-----------|
|
||||
| [questions-architecture.md](questions-architecture.md) | Technical foundation | Q-001, Q-006, Q-009, Q-018, Q-019, Q-020, Q-021, Q-022, Q-023, Q-029, Q-030, Q-046 |
|
||||
| [questions-perception.md](questions-perception.md) | Player observation | Q-003, Q-014, Q-016, Q-024, Q-025, Q-026, Q-051, Q-053, Q-054 |
|
||||
| [questions-content.md](questions-content.md) | Narrative, NPCs, setting | Q-010, Q-012, Q-013, Q-015, Q-017, Q-028, Q-031, Q-033, Q-040, Q-041, Q-042, Q-043, Q-044, Q-045, Q-047, Q-048, Q-049, Q-050, Q-052 |
|
||||
| [questions-scope.md](questions-scope.md) | Game concept, prototype | Q-002, Q-004, Q-005, Q-007, Q-008, Q-011, Q-027, Q-032, Q-034, Q-035, Q-036, Q-037, Q-038, Q-039 |
|
||||
|
||||
### Q-002: Scope of v0.1 playable prototype
|
||||
- **Status:** Map spec resolved ([D-014](scope.md#d-014-v01-map-specification)). Remaining: mechanics, characters, interactions for minimum playable build.
|
||||
- **Assigned to:** Full team
|
||||
## Status Summary
|
||||
|
||||
### Q-003: Art direction / presentation style
|
||||
- **Status:** Further resolved for v0.1 via Content Gap Analysis Workshop. Araminta's v0.1 Visual Grammar proposal covers: entity color palette (D-033), zone-specific color palettes (3 zones with hex values), fog rendering (4 states), monologue presentation spec, insert dual-character variants. Long-term art direction beyond v0.1 remains open.
|
||||
- **Remaining:** Long-term art direction, sprite replacement timeline, full visual identity.
|
||||
- **Assigned to:** Araminta (lead)
|
||||
| Domain | Total | Resolved | Partial | Open |
|
||||
|--------|-------|----------|---------|------|
|
||||
| Architecture | 12 | 6 | 1 | 5 |
|
||||
| Perception | 9 | 5 | 1 | 3 |
|
||||
| Content | 19 | 5 | 1 | 13 |
|
||||
| Scope | 14 | 0 | 1 | 13 |
|
||||
| **Total** | **54** | **16** | **4** | **34** |
|
||||
|
||||
### Q-004: One campaign spanning all eras or separate era scenarios?
|
||||
- **Status:** Not yet discussed
|
||||
- **Context:** Gore raised that Commonwealth Era and Void Era play very differently. Prototype focuses on pre-Starflyer War era.
|
||||
- **Assigned to:** Gore, Miri to lead discussion
|
||||
## Adding a Question
|
||||
|
||||
### Q-005: Scale for prototype - locations, characters, factions
|
||||
- **Status:** Partially scoped
|
||||
- **Early signal:** Institute/Armstrong City hub, ~10-20 characters, Guardians + institutional + political factions
|
||||
- **Assigned to:** Gestalt, Tyre, Miri
|
||||
1. Claim an ID: `tooling/db/decision claim Q questions "title"`
|
||||
2. Edit the appropriate domain file (`questions-{domain}.md`)
|
||||
3. Follow the existing format (`### Q-NNN: Title` heading)
|
||||
4. Update the domain file footer count
|
||||
5. Update this index: add the ID to the domain table row, update status summary
|
||||
|
||||
### Q-006: Multiplayer or single-player only?
|
||||
- **Status:** Resolved → [D-009](architecture.md#d-009-multiplayer---design-for-it-build-single-player-first)
|
||||
## Domain Guide
|
||||
|
||||
### Q-007: Target platform(s)
|
||||
- **Status:** Not yet discussed
|
||||
- **Context:** Team Leader has Linux background (Fedora). Cross-platform considerations?
|
||||
- **Assigned to:** Tyre
|
||||
When in doubt about where a question belongs: if it constrains **how we build**, it's architecture. If it defines **what the player observes or knows**, it's perception. If it defines **narrative, NPCs, dialogue, or setting**, it's content. If it defines **what we ship or how big it is**, it's scope.
|
||||
|
||||
### Q-008: Licensing / distribution model
|
||||
- **Status:** Not yet discussed
|
||||
- **Question:** Open source? Free? Commercial? This affects engine choice and asset decisions.
|
||||
- **Assigned to:** Team Leader
|
||||
|
||||
### Q-009: Time system
|
||||
- **Status:** Resolved → [D-031](architecture.md#d-031-time-system--game-clock-and-day-phases)
|
||||
|
||||
### Q-010: Storyteller AI design
|
||||
- **Status:** Not yet discussed
|
||||
- **Question:** How does the Rimworld-style storyteller work? What are the pacing rules? How much structural randomness vs dramatic randomness?
|
||||
- **Assigned to:** Gestalt, Nigel
|
||||
|
||||
### Q-011: Character selection and playable characters
|
||||
- **Status:** Not yet discussed
|
||||
- **Question:** Which characters are playable in the prototype? How different are their starting positions? Can you play canon characters or only original ones?
|
||||
- **Assigned to:** Miri, Paula
|
||||
|
||||
### Q-012: Generation expansion method for dialogue
|
||||
- **Status:** Open
|
||||
- **Question:** How does the 4x generation expansion pass work? LLM-based, template-based, or rule-based? Affects how base lines are authored — LLM needs style-strong anchors; rules need substitution patterns.
|
||||
- **Assigned to:** Gestalt, Mellanie
|
||||
- **Source:** Content Gap Analysis Workshop (Mellanie R2)
|
||||
|
||||
### Q-013: Line previewer temporal progression
|
||||
- **Status:** Open
|
||||
- **Question:** How does the line previewer (#193) handle THE FRIEND's multi-visit contradiction arc? Needs sequence mode to simulate interaction progression over multiple encounters.
|
||||
- **Assigned to:** Gestalt, Dudley
|
||||
- **Source:** Content Gap Analysis Workshop (Mellanie R2)
|
||||
|
||||
### Q-014: Audio timing with monologue chime
|
||||
- **Status:** Resolved → [D-067](perception.md#d-067-recognition-chime-fires-at-onset-of-cognitive-delay)
|
||||
- **Resolution:** Chime fires at ONSET of cognitive delay, not completion. 300-400ms duration, overlapping delay start. Chime is "unresolved" — opens a question, doesn't answer one. Sequence: hear/sense → chime plays → 0.6s delay begins → monologue text during delay → blob transitions to D-033 color → recognition complete.
|
||||
- **Date resolved:** 2026-02-16
|
||||
|
||||
### Q-015: Generation expansion for THE FRIEND content
|
||||
- **Status:** Open
|
||||
- **Question:** Does the 4x generation expansion apply to THE FRIEND's custom lines, or are all ~70-100 lines per FRIEND hand-authored? Recommendation: no expansion on FRIEND lines — risk of flattening hand-crafted voice.
|
||||
- **Assigned to:** Mellanie, Gestalt
|
||||
- **Source:** Content Gap Analysis Workshop (Ozzie R2, Gestalt R2)
|
||||
|
||||
### Q-016: Knowledge hierarchy for monologue prerequisites
|
||||
- **Status:** Resolved → [D-041](architecture.md#d-041-knowledge-graph-data-model)
|
||||
- **Resolution:** 4-level hierarchy: `Suspects < KnowsOf < KnowsDetails < Direct`. Suspects = "something's off", gates initial investigation and vague monologue. KnowsOf = "X is involved in Y", gates topic-specific dialogue and peer-tier access. KnowsDetails = actionable detail, gates confrontation and secret-tier dialogue. Direct = currently in LOS, provides live position data. Maps to D-028 access tiers and D-035 prerequisite tags.
|
||||
- **Date resolved:** 2026-02-11
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop
|
||||
|
||||
### Q-017: Triangle pressure threshold
|
||||
- **Status:** Open
|
||||
- **Question:** Triangles need a numeric pressure value that increases based on events. When it crosses a threshold, `confrontation` situation activates. What events increase pressure? What's the threshold?
|
||||
- **Assigned to:** Gestalt, Paula
|
||||
- **Source:** Content Gap Analysis Workshop (Gestalt R2)
|
||||
|
||||
### Q-018: Shadowcasting algorithm selection
|
||||
- **Status:** Resolved → [D-035](perception.md#d-035-symmetric-shadowcasting-albert-ford-selected-for-los-computation)
|
||||
- **Question:** Which line-of-sight algorithm should be used? Symmetric shadowcasting (Albert Ford) vs recursive shadowcasting. Both are proven but differ in symmetry properties (symmetric: if A sees B, then B sees A) and implementation complexity. Requires benchmarking at 150x150 map scale with 30 entities to validate performance within 100ms tick budget.
|
||||
- **Context:** D-011 mandates LOS shadowcasting for fog of perception. Architecture review identified this as unspecified (audit section 2.2). Critical for Sprint 2 perception pipeline.
|
||||
- **Assigned to:** Tyre, Dudley
|
||||
- **Source:** Architecture Review Audit 2026-02-11
|
||||
|
||||
### Q-019: Entity ID stability strategy
|
||||
- **Status:** Partially resolved → [D-041](architecture.md#d-041-knowledge-graph-data-model)
|
||||
- **Resolution:** Server-side: `StableEntityId` component + `EntityRegistry` resource provides bidirectional `StableId(u64) <-> Entity` mapping. StableId assigned once at entity spawn, never changes, persists across save/load. Knowledge graphs reference StableId, not bevy Entity. Client-side mapping (Godot StableId -> scene node lifecycle) remains open.
|
||||
- **Remaining:** Client-side entity lifecycle management, scene node mapping strategy.
|
||||
- **Date partially resolved:** 2026-02-11
|
||||
- **Assigned to:** Tyre, Dudley (client-side portion)
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop
|
||||
|
||||
### Q-020: Multi-entity collision resolution
|
||||
- **Status:** Open
|
||||
- **Question:** When two NPCs attempt to move to the same tile on the same tick, what is the resolution policy? Options: first-write-wins (deterministic with system ordering), both fail (conservative), priority-based (e.g., player > NPC, Active tier > Background tier).
|
||||
- **Context:** D-012 defines tile collision. WalkabilityMap exists (server/src/simulation/movement.rs) but handles single-entity validation. Architecture review identified multi-entity collision as unspecified.
|
||||
- **Assigned to:** Gestalt, Dudley
|
||||
- **Source:** Architecture Review Audit 2026-02-11
|
||||
|
||||
### Q-021: Tick budget overflow policy
|
||||
- **Status:** Open
|
||||
- **Question:** When a simulation tick exceeds the 100ms budget, what happens? Options: (1) slow down real-time and preserve determinism (tick completes fully before next), (2) skip ticks and break determinism, (3) cap work per tick and defer to next tick. Must align with D-010 principle 4 (deterministic simulation).
|
||||
- **Context:** D-026 defines 100ms tick budget for Active tier at 10 tps. Architecture review consensus recommendation proposes "slow real-time, don't skip ticks." Needs formal decision.
|
||||
- **Assigned to:** Tyre, Dudley
|
||||
- **Source:** Architecture Review Audit 2026-02-11
|
||||
|
||||
### Q-022: NPC pathfinding cache eviction
|
||||
- **Status:** Open
|
||||
- **Question:** With 80 Active-tier NPCs each caching ~3 pathfinding routes, the cache holds ~240 paths. What is the eviction policy? LRU? Time-based expiration? Fixed size per NPC? How are paths invalidated when walkability changes (doors lock, areas become restricted)?
|
||||
- **Context:** Architecture review identified pathfinding as MEDIUM gap (audit section 2.2). Cache management needs specification regardless of algorithm choice.
|
||||
- **Assigned to:** Tyre, Dudley
|
||||
- **Source:** Architecture Review Audit 2026-02-11
|
||||
|
||||
### Q-023: Debug visualization scope
|
||||
- **Status:** Open
|
||||
- **Question:** What information should the debug overlay display? Candidates: LOS rays, pathfinding waypoints, vision cones, information boundary tags (who knows what), tick timing breakdown, spatial partition grid cells. Dev-only tool, or accessible for mod development?
|
||||
- **Context:** Architecture review (Troblum) identifies debug visualization as missing operational infrastructure. Needed for debugging perception system, information boundaries, and performance issues.
|
||||
- **Assigned to:** Tyre, Stig
|
||||
- **Source:** Architecture Review Audit 2026-02-11
|
||||
|
||||
### Q-024: Gossip propagation timing
|
||||
- **Status:** Resolved → [D-080](perception.md#d-080-npc-to-npc-knowledge-propagation)
|
||||
- **Resolution:** Knowledge transfer occurs via a separate `transfer_npc_knowledge` Bevy system running `after(run_npc_conversations)`. Transfer fires once per conversation at conversation start (immediate during conversation, not queued). Rate: 1–3 facts drawn by recency. Trust-tier gated. See D-080 for full specification.
|
||||
- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — unanimous. 2026-02-24.
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt Round 1); resolved in Knowledge Flow & NPC Information Boundaries Workshop Round 2.
|
||||
|
||||
### Q-025: Knowledge graph cap and eviction strategy
|
||||
- **Status:** Resolved — no cap or eviction needed for v0.1/v0.2. Re-evaluation trigger: Active NPC count > 200 OR KG memory exceeds 50 MB.
|
||||
- **Question:** At what point does an NPC's knowledge graph need entry eviction? What is the eviction policy?
|
||||
- **Resolution (2026-02-24, confirmed by Knowledge Flow workshop):** Current analysis: ~14 KB per Active NPC KG (50 entities + 20 facts, D-041 budget). 80 Active NPCs = ~1.1 MB. 2,000 Background NPCs at 10 entries = ~5 MB. Total ~6 MB. With gossip propagation shipping in Sprint 17 (D-080, 1–3 facts per conversation): estimated ~12 MB peak at current NPC counts. Neither re-evaluation condition expected before v0.3. The existing decay system (`decay_knowledge` in `knowledge/events.rs`) downgrades confidence and marks entries Stale but does not remove them — correct behavior (preserves "I used to know X" for narrative). If eviction becomes necessary, simplest policy: on each decay pass, if entities.len() > MAX_ENTITIES, remove Stale entries with lowest last_updated_tick. BTreeMap makes this O(N).
|
||||
- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — Tyre, Gestalt, Dudley confirmed; Paula non-objection noted. 2026-02-24.
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop (Dudley Round 1, section 8.3). Architecture audit 2026-02-23. Workshop confirmation 2026-02-24.
|
||||
|
||||
### Q-026: Contradiction detection algorithm
|
||||
- **Status:** Resolved → [D-083](perception.md#d-083-contradiction-detection-pipeline)
|
||||
- **Resolution:** Event-driven detection at KG write time in `observe_entity()`, using `ContradictionClaim` struct. Location contradiction is automatic (Sprint 17): position comparison + time window (CONTRADICTION_WINDOW_TICKS = 600). Attribute and fact contradiction are content-authored (Sprint 18). Both ToldBy and DirectObservation entries receive Contradicted state (epistemic neutrality). `ContradictionDetected` event → monologue with resolved display names → relationship shift → AnomalyMarker via existing pipeline.
|
||||
- **Closed by:** Knowledge Flow & NPC Information Boundaries Workshop — unanimous on architecture. 2026-02-24.
|
||||
- **Source:** Knowledge Graph & Information Boundaries Workshop (Gestalt/Paula Round 1); resolved in Knowledge Flow & NPC Information Boundaries Workshop Round 2.
|
||||
|
||||
### Q-027: Fast-travel system design
|
||||
- **Status:** Open
|
||||
- **Question:** How does inter-system travel work in production gameplay? The current hub teleport (Home key, #501) is scoped as Gauntlet-only dev tool. Production travel must be diegetic and respect asymmetric information. Proposed flow: player goes to local gate → warps to system gate → interacts with target menu → jumps to destination system gate. Key constraints:
|
||||
1. **Region gating:** fast-travel only available from safe or fast-travel-enabled regions. If you rented transport to reach a remote location (e.g. mountain colony), you must return the transport to civilization first — this can be a skip-travel interaction but must happen in-world.
|
||||
2. **Asymmetric information:** NPCs observe arrivals and departures. Travel choices leak information (who saw you leave, who sees you arrive, what transport was used).
|
||||
3. **Home key in production:** at most, Home could prompt "Do you want to fast-travel to the system hub?" if in a safe/enabled region — never instant teleport.
|
||||
4. **Transport types:** walking, rented vehicle, public transit, gate network — each with different information exposure profiles.
|
||||
- **Context:** #501 implemented instant Home key teleport gated behind `gauntlet_mode`. Re-scoped to Gauntlet-only after design review. Production fast-travel needs separate design and implementation.
|
||||
- **Assigned to:** Gestalt, Paula, Tyre
|
||||
- **Source:** Sprint 10 PR review discussion (2026-02-19)
|
||||
|
||||
### Q-028: Collision-resistant line IDs for auto-generated NPCs
|
||||
- **Status:** Resolved → [D-084](content.md#d-084-dual-namespace-line-id-scheme--role-pool--instance-override)
|
||||
- **Resolution:** The collision problem is mostly already solved by the role-pool architecture: `dock-worker_d_###` lines are shared content for all instances of the role, not per-instance IDs. A true collision (two distinct authored lines sharing the same ID) cannot occur with one file per role. For the edge case of authored instance-specific content, a role-slug + zero-padded generation counter suffix produces `dock-worker-07_d_001`. Counter is seeded-deterministic. No schema change, no migration. Hand-authored NPCs unchanged.
|
||||
- **Closed by:** Gestalt (Sprint 18, #544). 2026-02-25.
|
||||
- **Ticket:** #544
|
||||
- **Assigned to:** Gestalt, Tyre
|
||||
- **Source:** Sprint 16 PR #59 review discussion (2026-02-23)
|
||||
|
||||
### Q-029: Save file format design
|
||||
- **Status:** Open
|
||||
- **Question:** What should the long-term save file format look like? Key considerations:
|
||||
1. **Versioning and migration:** How do saves survive across game versions? Schema evolution strategy (field additions, renames, removals). Should saves embed a version number and run migrations on load?
|
||||
2. **Compression:** Raw MessagePack vs compressed (zstd, lz4)? Tradeoff between save/load speed and file size. SaveStateV1 is already MessagePack — does that carry forward?
|
||||
3. **Integrity:** Checksums or signatures to detect corruption? CRC32 header?
|
||||
4. **Metadata header:** Should the file have a readable header (game version, save date, play time, character name) that the loading screen can read without deserializing the full save?
|
||||
5. **Determinism:** D-010 requires deterministic simulation. Can saves capture enough state to resume deterministically, or is approximate resume acceptable?
|
||||
6. **Modding:** Should the format be documented for mod authors? Does it need extension points?
|
||||
7. **Cloud sync:** Any considerations for Steam Cloud or similar? File size limits?
|
||||
- **Context:** Sprint 19 implements a quick-and-dirty save format (D-085 per-game directories, MessagePack serialization from SaveStateV1). This question tracks the thorough design pass for production quality.
|
||||
- **Assigned to:** Tyre, Dudley
|
||||
- **Source:** Team Leader directive (Sprint 19 planning)
|
||||
|
||||
### Q-030: Seed configuration schema
|
||||
- **Status:** Open
|
||||
- **Question:** What artifact records all randomizer decisions at game start? The wiki-review workshop proposed a `seed-state.yaml` capturing: world seed, character selection, pool draws (Tier 1 modules, FRIEND selection, contraband variant), template assignments, NPC trait rolls, triangle configurations, and entanglement pattern. Ticket #394 (seed configuration schema design) exists but the design is open.
|
||||
- **Assigned to:** Tyre, Gestalt
|
||||
- **Source:** Wiki Review Workshop + v0.1 Content Scoping Workshop
|
||||
|
||||
### Q-031: Combined content style guide
|
||||
- **Status:** Open
|
||||
- **Question:** Should the project have a single combined content style guide merging Paula's tier templates, Mellanie's voice conventions, Gestalt's mechanical constraints, and Miri's regional guide? The wiki-review workshop proposed this as a deliverable but it was never authored. What format, who owns it, and does it block content authoring?
|
||||
- **Assigned to:** Mellanie, Paula
|
||||
- **Source:** Wiki Review Workshop R2
|
||||
|
||||
### Q-032: Cultural ingredients menu
|
||||
- **Status:** Open
|
||||
- **Question:** Should world generation use a 6-category cultural ingredients menu (Heritage Roots, Settlement Motivation, Economic Function, Philosophical Alignment, Corporate/Faction Presence, Drift Stage) where each culture is composed by selecting from ingredient lists? The lead approved the "ingredients menu" model over fixed cultural taxonomies. Full specification needed: category definitions, ingredient lists per category, composition rules, absence-as-signal mechanics.
|
||||
- **Assigned to:** Miri, Gestalt
|
||||
- **Source:** Wiki Review Workshop R4, lead interview
|
||||
|
||||
### Q-033: Three-system NPC architecture
|
||||
- **Status:** Open
|
||||
- **Question:** Should NPCs be formally composed from 9 thematic patterns (FRIEND, MIRROR, ANCHOR, GHOST, CATALYST, THRESHOLD, REMNANT, SYSTEM, NOBODY) x 6 functional motivations (HANDLER, WITNESS, TURNCOAT, CIVILIAN, OPERATOR, SKEPTIC)? D-024 defines 10 axes + combat but predates this refined system. The wiki-review workshop produced a full composition matrix with drama ratings and forbidden combinations. Does this supersede D-024 or extend it?
|
||||
- **Assigned to:** Gestalt, Paula
|
||||
- **Source:** Wiki Review Workshop R4
|
||||
|
||||
### Q-034: PC archetypes
|
||||
- **Status:** Open
|
||||
- **Question:** Should the full game support 8 fluid PC archetypes (Smuggler, Detective, Engineer, Diplomat, Medic, Scholar, Soldier, Merchant) with transition mechanics where archetype shifts during play based on player behavior? The lead approved 8 archetypes with fluid transitions as a game mechanic. v0.1 ships smuggler + detective only (D-027). Full archetype spec, transition triggers, and "vulnerable window" mechanics are undesigned. NOTE: The character-creation-game-setup workshop (Q-011) will address this — coordinate.
|
||||
- **Assigned to:** Nigel, Gestalt
|
||||
- **Source:** Wiki Review Workshop R4, lead interview
|
||||
|
||||
### Q-035: Sacred/Profane/Middle Kingdom framework
|
||||
- **Status:** Open
|
||||
- **Question:** Should all game systems map to a Sacred/Profane/Middle Kingdom architectural framework? The lead approved this model where Sacred = what the system protects, Profane = what threatens it, Middle Kingdom = where the player navigates. The wiki-review workshop produced a full mapping table covering information, social, economic, spatial, temporal, and narrative systems. Needs formal specification and validation against current architecture.
|
||||
- **Assigned to:** Gore, Gestalt
|
||||
- **Source:** Wiki Review Workshop R4, lead interview
|
||||
|
||||
### Q-036: District skeleton as generator output
|
||||
- **Status:** Open
|
||||
- **Question:** For the 300-world model, should the district skeleton (social sites, NPC slots, triangle templates, economic function, access topology) be the atomic output unit of the world generator? D-025 defines social sites as the atomic template unit for hand-authoring. The generator model reframes the district as a composed output from ingredient inputs. How does this interact with D-025?
|
||||
- **Assigned to:** Tyre, Gestalt
|
||||
- **Source:** Wiki Review Workshop R4
|
||||
|
||||
### Q-037: Generator development pipeline
|
||||
- **Status:** Open
|
||||
- **Question:** Should content production follow a 6-phase generator pipeline (Ingredient Authoring, Template Authoring, Generator Development, Validation Development, Generation + Review, Hand-Elevation)? The wiki-review workshop proposed this as the production model for 300 worlds. SI mapped a release path (v0.1 hand-authored, v0.2-0.5 template expansion, v0.6-0.10 generator development, pre-v1.0 validation). Needs scope assessment and sprint planning integration.
|
||||
- **Assigned to:** SI, Tyre
|
||||
- **Source:** Wiki Review Workshop R4
|
||||
|
||||
### Q-038: Authored content estimate at 300-world scale
|
||||
- **Status:** Open
|
||||
- **Question:** What is the irreducible authored content volume for 300 worlds? The wiki-review workshop estimated ~1,600-2,800 hours of hand-authoring for generator inputs (ingredient definitions, template specifications, validation rules, hand-elevation passes). How does this compare to the 20-district hand-authoring model it replaced? Is this estimate still valid given subsequent architectural decisions?
|
||||
- **Assigned to:** Mellanie, SI
|
||||
- **Source:** Wiki Review Workshop R4
|
||||
|
||||
### Q-039: Gate topology generation
|
||||
- **Status:** Open
|
||||
- **Question:** How should the world generator produce gate (wormhole) network topology for 300 worlds? The wiki-review workshop proposed: gate connectivity = Sacred (what connects), which worlds connect = Profane (what separates), accessible world count = Middle Kingdom (where the player navigates). Small-world network properties, hub-and-spoke vs mesh topology, and Sacred/Profane constraints on gate placement are all unresolved. D-012 covers chunk-based map architecture but predates the 300-world model.
|
||||
- **Assigned to:** Tyre, Nigel
|
||||
- **Source:** Wiki Review Workshop R4
|
||||
|
||||
### Q-040: Gate dual-use topology — freight and commuter on shared span gate infrastructure
|
||||
- **Status:** Resolved → D-093 (gate cluster zone spec), D-095 (span gate dual-use windows)
|
||||
- **Question:** System span gates serve both freight and commuter traffic (one gate per system). How does this work physically? Is it one gate aperture with scheduling (freight window vs. passenger window), or parallel lanes (separate apertures for freight and passenger flows)? What does the gate facility look like from the inside — a single large bay or divided infrastructure?
|
||||
- **Layout implication:** Affects the gate cluster spatial design in the Transit District — the gate cluster must accommodate both freight staging and passenger throughflow, possibly at different times of day.
|
||||
- **Assigned to:** Miri
|
||||
- **Source:** Station District Layout Workshop (#153), Round 2. Surfaced by lead correction to Miri's S-02 (commuter transit ≠ second external gate).
|
||||
- **Cross-reference:** D-036 (Sova setting), Q-036 (district skeleton as generator output)
|
||||
|
||||
### Q-041: Interstellar travel mechanics — horizon stations and gate architecture
|
||||
- **Status:** Resolved → D-095 (horizon stations: alien-built, 4–8 apertures, Oort-cloud, "The Ring"; sequential hop travel)
|
||||
- **Question:** A system needs MORE than one horizon gate for multi-hop connectivity (one gate allows only 1:1 connections). Lead proposal (Round 3): **Horizon stations** — orbital installations at Oort-cloud distance, partially or wholly understood ancient alien technology, self-maintaining (Mass Effect relay/Citadel analog). Each horizon station holds a FIXED number of active and inactive horizon gates. Some systems may only have one hop to an orbital customs station with no direct planet-side span gate access. Remaining questions: How many gates per horizon station? What determines which gates are active vs. inactive? Is the travel instantaneous or traversal-based? What is "The Ring" (the orbital horizon station) like as a physical space?
|
||||
- **Assigned to:** Miri
|
||||
- **Source:** Station District Layout Workshop (#153), Round 2. Lead correction in Round 3: single-gate-per-system model insufficient for multi-hop travel; horizon station model proposed.
|
||||
- **Cross-reference:** D-036 (Sova setting), Q-039 (gate topology generation), Q-040 (gate dual-use topology)
|
||||
|
||||
### Q-042: Intra-system transport networks — passenger vs. freight, vehicles and modes
|
||||
- **Status:** Partially resolved → D-095 (span gates at star/planetary level; horizon stations at Oort distance). Intra-system hab-to-hab transit remains open.
|
||||
- **Question:** How do people and goods move within a star system (between orbital stations, planetary surfaces, and other in-system facilities)? Are there two separate networks (passenger transport and freight transport) or one shared network? What are the vehicle types and transit modes? How does intra-system transit interact with the span gate at the system's hub station?
|
||||
- **Assigned to:** Miri
|
||||
- **Source:** Station District Layout Workshop (#153), Round 2. Flagged by lead as transport lore requiring formal tracking.
|
||||
- **Cross-reference:** D-036 (Sova setting), Q-043 (station internal transit)
|
||||
|
||||
### Q-043: Station internal transit — intra-station transport system between districts
|
||||
- **Status:** Resolved → D-095 (The Loop: 6-district tram, 4-minute Residential Core → Transit District; transit platform is bar-side encounter node)
|
||||
- **Question:** What is the intra-station transport system on Station Sova? How do workers commute between districts (e.g., Residential Core → Transit District)? Is it a train, tram, shuttle, or pressurised corridor? What is the travel time and frequency? Where does the transit stop sit within the Transit District — gate-cluster-adjacent (workers arrive near freight operations) or bar-side-adjacent (workers arrive near their social space)?
|
||||
- **Layout implication for #153:** The Transit District must include an internal transit stop. Its position within the district affects NPC traffic patterns and the district entry topology. This is the active T-03b question for Round 2/3 of the Station District Layout Workshop.
|
||||
- **Assigned to:** Miri
|
||||
- **Source:** Station District Layout Workshop (#153), Round 2. Arose from lead correction: commuter transit = internal station transit, not a second external gate.
|
||||
- **Cross-reference:** D-036 (Sova setting), Q-042 (intra-system transport networks), S-02 revision
|
||||
|
||||
### Q-044: Gate-train integration — do transport vehicles use gates directly or transfer on each side
|
||||
- **Status:** Resolved → D-093/D-095 (gates are pedestrian/cargo-only; passengers transfer via gate concourse → transition corridor → transit platform; no direct gate-to-tram connection)
|
||||
- **Question:** If trains or shuttles are the intra-system or intra-station transit mode, do they use the span gate directly (a train enters the gate and exits at the destination, carriages and all)? Or are the gates pedestrian/cargo-only, requiring passengers and freight to transfer to separate transport on each side? What does this imply for gate terminal design — does it need platforms, or just processing space?
|
||||
- **Assigned to:** Miri
|
||||
- **Source:** Station District Layout Workshop (#153), Round 2. Flagged by lead as transport lore requiring formal tracking.
|
||||
- **Cross-reference:** Q-040 (gate dual-use topology), Q-043 (station internal transit)
|
||||
|
||||
---
|
||||
|
||||
### Q-045: Axis 11 — Network Footprint NPC tag
|
||||
- **Status:** Open
|
||||
- **Priority:** High
|
||||
- **Question:** Should the NPC model (D-024, 10 axes) gain an 11th axis: `network_footprint: Option<NetworkFootprintTag>` for NPCs who are locally insignificant in appearance but carry network-significant information or are relevant to external actors? Default `None` for procedural NPCs. Explicitly set for authored scenario NPCs. This would enable the storyteller to identify locally-invisible but network-critical nodes without breaking the NPC's mundane character.
|
||||
- **Context:** Raised during Generator Architecture Workshop (#562). The Ysabel Vorn litmus test (4.5/5 playstyle hooks, Backwater/Moderate setting) demonstrated that locally-insignificant NPCs can be key network nodes. Without this field, the generator has no mechanism to flag them to the storyteller.
|
||||
- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §NPC Model.
|
||||
- **Assigned to:** Miri
|
||||
|
||||
### Q-046: Departure schedule model — departure windows as generator output for docked vessels
|
||||
- **Status:** Resolved → D-108 (MobileChunk Specification)
|
||||
- **Resolution:** `scheduled_departure: Option<SimTick>` in `Docked` state is mandatory generator output. Vessels without departure schedules are an error state. The `Docked` struct must include `docked_since: SimTick` and `scheduled_departure: Option<SimTick>` — these fields must be added at implementation time (absent from Tyre's Round 4 canonical struct).
|
||||
- **Date resolved:** 2026-02-27
|
||||
- **Source:** Generator Architecture Workshop (#562)
|
||||
- **Assigned to:** Tyre + Miri
|
||||
|
||||
### Q-047: Mobile environment social arc — structural representation of journey timeline
|
||||
- **Status:** Open
|
||||
- **Priority:** Medium
|
||||
- **Question:** How is the social arc of a mobile environment journey (BoundedLinear / BoundedMobile) represented structurally? The journey has a beginning (boarding, strangers), middle (established dynamic), and end (departure, relationship crystallized). What game structures capture this timeline and enable the storyteller to intervene? Does `TransitSocialModifier` need a journey-phase field?
|
||||
- **Context:** Ozzie's player experience requirement from Generator Architecture Workshop (#562): "the journey must have a social arc — not just social presence." The stage+cast framing is correct; the formal structural representation is unspecified.
|
||||
- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §Open Questions.
|
||||
- **Assigned to:** Miri + Gestalt
|
||||
|
||||
### Q-048: DramaDensity enum naming — 3-level vs 5-level
|
||||
- **Status:** Open
|
||||
- **Priority:** Low
|
||||
- **Question:** The Round 4 struct uses `Quiescent / Active / Intense` (3 levels). Round 3 proposed `Zero / Low / Medium / High / Flashpoint` (5 levels). Which should be canonical? Nigel's position: `Flashpoint` should be preserved as a distinct peak value — it is the storyteller's maximum-pressure instrument and should not collapse into `Intense`. If 3 levels are chosen for implementation simplicity, `Flashpoint` should still be the distinct peak name, not `Intense`.
|
||||
- **Context:** ComplexityTier → DramaDensity ceiling (established): Full → any intensity; Moderate → Active max; Minimal → Quiescent max; Empty → Zero only. Naming must be consistent with these ceiling values.
|
||||
- **Source:** Generator Architecture Workshop (#562), Rounds 3–4. `docs/workshops/generator-architecture/workshop-outcomes.md` §Open Questions.
|
||||
- **Assigned to:** Tyre + Gestalt
|
||||
|
||||
### Q-049: ObjectTag vocabulary co-maintenance — Miri and Araminta shared dependency
|
||||
- **Status:** Open
|
||||
- **Priority:** Medium
|
||||
- **Question:** The `ObjectTag` vocabulary must be co-maintained between Miri's `HeritageGrammarOverlay` (cultural grammar, Rust struct) and Araminta's asset categorization (visual expression, TOML files). What is the governance model? Who owns the canonical tag list? How are additions and deprecations coordinated? Does the vocabulary live in the Rust struct definition or in a shared data file?
|
||||
- **Context:** If the vocabulary diverges, the generator will reference tags that don't exist in asset categories, or assets will be authored that the grammar never references. This is a silent correctness failure.
|
||||
- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §D-READY-9.
|
||||
- **Assigned to:** Miri + Araminta
|
||||
|
||||
### Q-050: Assassination difficulty synthesis — formal spec combining stored baseline with on-demand computation
|
||||
- **Status:** Open
|
||||
- **Priority:** Medium
|
||||
- **Question:** Formal specification needed for the synthesis combining stored cultural baseline (`DerivedDistrictAnalysis` on Phase 1 skeleton) with on-demand runtime computation for player-facing assessment. Key constraint from Miri: **on-demand computation is display-only** — all game logic (tactical triangle instantiation, guarantee audit) uses the Phase 1 `DerivedDistrictAnalysis` value. The on-demand computation is subordinate to the stored baseline, not a replacement.
|
||||
- **Context:** Minor tension between Gestalt's "computed entirely on demand" position and Miri's "stored cultural baseline" position. Synthesis accepted by both participants in Generator Architecture Workshop (#562); formal spec needed for implementation.
|
||||
- **Source:** Generator Architecture Workshop (#562), Round 4. `docs/workshops/generator-architecture/workshop-outcomes.md` §Open Questions.
|
||||
- **Assigned to:** Gestalt + Miri
|
||||
|
||||
---
|
||||
|
||||
*50 questions (12 resolved, 2 partially resolved, 36 open). Last updated: 2026-02-27 (Q-045 through Q-050 added — Generator Architecture Workshop #562; Q-046 resolved immediately by D-108)*
|
||||
*54 questions. Last updated: 2026-02-28 (split from single file into domain files).*
|
||||
|
||||
@@ -2,10 +2,28 @@
|
||||
|
||||
Ticket: #430 | Decision: D-059 | Sprint: 6
|
||||
Author: Tyre (architecture) | Implementer: Stig
|
||||
Updated: Sprint 22 (#569 simplification + #563 alpha tuning) | Stig + Araminta
|
||||
|
||||
## Current State (Sprint 22)
|
||||
|
||||
The fog system was simplified from the original 5-layer spec in Sprint 22 (#569):
|
||||
|
||||
| Original D-059 Layer | Sprint 22 Status | Notes |
|
||||
|----------------------|------------------|-------|
|
||||
| 1. Clear (forward cone) | Implemented | Soft Gaussian gradient (7x7, sigma 2.0) |
|
||||
| 2. Light fog (cone gradient) | Simplified — merged into gradient | Peripheral sector removed from server (#569); Gaussian blur provides soft transition |
|
||||
| 3. Deep fog (previously explored) | Implemented — EXP_EXPLORED | Alpha 0.55-0.70 with zone temperature tint (#563) |
|
||||
| 4. Unexplored + maps app | Deferred | v0.1.2+, requires mapped_tiles in ObserverSnapshot |
|
||||
| 5. Unexplored (no maps) | Implemented | Solid near-black #12141a |
|
||||
|
||||
**Alpha values (Sprint 22, #563):**
|
||||
- Light fog zone (near cone gradient, vis 0.0-0.3): alpha 0.25-0.35, breathing ±0.05 (8-10s)
|
||||
- Deep fog zone (EXP_EXPLORED, vis≈0): alpha 0.55-0.70, breathing ±0.075 (15-20s)
|
||||
- Zone temperature tint active in deep fog zone (D-059/D-046/D-077)
|
||||
|
||||
## Overview
|
||||
|
||||
Complete rewrite of the fog system. Delete `fog_renderer.gd` (TileMapLayer-based, 2-state binary fog) and replace with a shader-driven, 5-layer fog system on a CanvasGroup.
|
||||
Complete rewrite of the fog system. Delete `fog_renderer.gd` (TileMapLayer-based, 2-state binary fog) and replace with a shader-driven fog system on a CanvasGroup.
|
||||
|
||||
The fog is **knowledge-graph-driven** — the same fog shows different information per character based on their KG. "Fog is not darkness — it's the absence of your attention."
|
||||
|
||||
@@ -40,9 +58,8 @@ The initial instinct is to put a fragment shader on the CanvasGroup itself (as `
|
||||
```
|
||||
Server (each tick)
|
||||
└─ ObserverSnapshot
|
||||
├─ visible_positions: Dictionary<Vector2i, true> (LOS result)
|
||||
├─ visibility_sectors: Dictionary<Vector2i, "Forward"|"Peripheral">
|
||||
└─ visible_tiles: Array<{x, y, z, type}> (known map extent)
|
||||
├─ visible_positions: Dictionary<Vector2i, true> (LOS result, forward cone only)
|
||||
└─ visible_tiles: Array<{x, y, z, type, zone_id}> (known map extent + zone metadata)
|
||||
|
||||
GameState (autoload)
|
||||
└─ Stores all above
|
||||
@@ -60,7 +77,6 @@ FogOverlay (Node2D) [fog_shader.gd]
|
||||
│ uniform sampler2D exploration_tex; # Historical explored
|
||||
│ uniform sampler2D zone_tint_tex; # Zone temperature colors
|
||||
│ uniform float time; # For noise animation
|
||||
│ uniform vec2 player_pos; # Vision cone center
|
||||
│ uniform vec2 map_offset; # World-to-texture mapping
|
||||
│ uniform vec2 map_size; # Texture dimensions in tiles
|
||||
└─ FogEntities (Node2D)
|
||||
@@ -89,13 +105,13 @@ var zone_tint_texture: ImageTexture
|
||||
|
||||
func update_from_state() -> void:
|
||||
# Called every tick by fog_shader.gd
|
||||
# 1. Resize textures if map_bounds changed
|
||||
# 1. Resize textures if map_bounds changed (grow-only)
|
||||
# 2. Clear visibility_image to 0 (black)
|
||||
# 3. Write visible_positions from GameState → red channel = 255
|
||||
# 4. Write visibility_sectors: Forward = 255, Peripheral = 180
|
||||
# 5. Update exploration_image: any currently-visible pixel → 255,
|
||||
# previously-visible pixels decay toward 128 over time
|
||||
# 6. Upload images to textures
|
||||
# 3. Write visible_positions from GameState → red channel = 255 (forward cone only)
|
||||
# 4. Update exploration_image: visible pixels → 255,
|
||||
# tiles leaving LOS decay to 128 (EXP_EXPLORED)
|
||||
# 5. Update zone_tint_image: write zone_id → temperature color per tile
|
||||
# 6. Upload changed images to textures
|
||||
```
|
||||
|
||||
**Performance note:** `Image.set_pixel()` in a loop is ~0.05ms for 400 tiles. Acceptable. For larger maps, switch to `Image.set_data()` with a pre-built `PackedByteArray`.
|
||||
@@ -104,79 +120,36 @@ func update_from_state() -> void:
|
||||
|
||||
File: `client/shaders/fog.gdshader`
|
||||
|
||||
The shader determines fog layer per pixel based on the visibility and exploration textures.
|
||||
The shader determines fog state per pixel based on the visibility and exploration textures.
|
||||
Two explored sub-zones are distinguished by the blurred `vis` value (proximity to the forward cone):
|
||||
|
||||
```glsl
|
||||
shader_type canvas_item;
|
||||
// Light fog noise — fast cycle (8-10s), subtle ±0.05 breathing
|
||||
float noise_fast = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
|
||||
// Deep fog noise — slow cycle (15-20s), more pronounced ±0.075 breathing
|
||||
float noise_slow = texture(noise_tex, tile * 0.02 + vec2(time * 0.05, time * 0.035)).r;
|
||||
|
||||
uniform sampler2D visibility_tex : filter_nearest;
|
||||
uniform sampler2D exploration_tex : filter_nearest;
|
||||
uniform sampler2D zone_tint_tex : filter_nearest;
|
||||
uniform vec2 map_offset; // World position of texture origin (in pixels)
|
||||
uniform vec2 map_size; // Texture size in tiles
|
||||
uniform float tile_size; // Pixels per tile
|
||||
uniform float time; // Engine TIME for noise animation
|
||||
// Light fog: alpha 0.25-0.35 (near cone gradient)
|
||||
float light_fog_alpha = 0.30 + (noise_fast * 2.0 - 1.0) * 0.05;
|
||||
// Deep fog: alpha 0.55-0.70 (far from cone, EXP_EXPLORED)
|
||||
float deep_fog_alpha = 0.625 + (noise_slow * 2.0 - 1.0) * 0.075;
|
||||
|
||||
// Fog layer colors
|
||||
const vec4 FOG_UNEXPLORED = vec4(0.071, 0.078, 0.102, 1.0); // #12141a
|
||||
const vec4 FOG_WIREFRAME = vec4(0.2, 0.2, 0.251, 1.0); // #333340
|
||||
const float LIGHT_FOG_DESAT = 0.45; // 40-50% desaturation
|
||||
const float LIGHT_FOG_DIM = 0.7; // brightness -30%
|
||||
const float DEEP_FOG_DESAT = 0.9; // near-monochrome
|
||||
const float DEEP_FOG_DIM = 0.25; // heavy dimming
|
||||
const float ZONE_TINT_STRENGTH = 0.1; // ~10% zone temperature tint
|
||||
// Blend deep <-> light fog by proximity to cone:
|
||||
// vis=0 (far from cone) → deep_factor=1 → deep fog color + alpha
|
||||
// vis=0.3 (cone gradient) → deep_factor=0 → light fog color + alpha
|
||||
// vis=0.85+ (inside cone) → clarity=1 → transparent (clear)
|
||||
float deep_factor = 1.0 - smoothstep(0.0, 0.30, vis);
|
||||
float fog_alpha = mix(light_fog_alpha, deep_fog_alpha, deep_factor);
|
||||
|
||||
// Perlin noise (simplified — use Godot's NoiseTexture2D for production quality)
|
||||
// Alternatively: pass a pre-generated noise texture as another uniform.
|
||||
|
||||
void fragment() {
|
||||
// Map screen pixel to tile coordinate
|
||||
vec2 world_pos = (SCREEN_UV * vec2(textureSize(visibility_tex, 0))) ;
|
||||
vec2 tile_uv = world_pos / map_size;
|
||||
|
||||
// Sample textures
|
||||
float vis = texture(visibility_tex, tile_uv).r; // 0-1: current visibility
|
||||
float explored = texture(exploration_tex, tile_uv).r; // 0-1: exploration state
|
||||
vec3 zone_tint = texture(zone_tint_tex, tile_uv).rgb;
|
||||
|
||||
// Determine fog layer:
|
||||
// vis > 0.7 → Layer 1: Clear (vision cone) — soft gradient edge
|
||||
// vis > 0.3 → Layer 2: Light fog (peripheral) — desaturated, noise
|
||||
// explored > 0.4 → Layer 3: Deep fog (previously explored) — monochrome + tint
|
||||
// explored > 0.1 → Layer 4: Unexplored + maps — wireframe outlines
|
||||
// else → Layer 5: Unexplored, no maps — solid near-black
|
||||
|
||||
if (vis > 0.7) {
|
||||
// Layer 1: Clear — soft gradient at edge
|
||||
float edge = smoothstep(0.7, 1.0, vis);
|
||||
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge); // Transparent in clear zone
|
||||
} else if (vis > 0.3) {
|
||||
// Layer 2: Light fog — desaturated + dim + animated noise
|
||||
float noise = _perlin(world_pos * 0.02 + vec2(time * 0.1, time * 0.05));
|
||||
float alpha = mix(0.4, 0.6, noise); // Animated fog density
|
||||
COLOR = vec4(0.02, 0.02, 0.05, alpha);
|
||||
} else if (explored > 0.4) {
|
||||
// Layer 3: Deep fog — near-monochrome + zone tint + breathing noise
|
||||
float noise = _perlin(world_pos * 0.01 + vec2(time * 0.03, time * 0.02));
|
||||
vec3 tint = mix(vec3(0.05), zone_tint, ZONE_TINT_STRENGTH);
|
||||
float alpha = mix(0.75, 0.85, noise); // Fog breathes
|
||||
COLOR = vec4(tint, alpha);
|
||||
} else if (explored > 0.1) {
|
||||
// Layer 4: Unexplored + maps app — geometric wireframe
|
||||
COLOR = FOG_WIREFRAME;
|
||||
// TODO: wireframe grid line overlay (1px every tile_size pixels)
|
||||
} else {
|
||||
// Layer 5: Unexplored, no maps — information zero
|
||||
COLOR = FOG_UNEXPLORED;
|
||||
}
|
||||
}
|
||||
// Zone temperature tint (D-059/D-046/D-077): deep fog color = zone tint
|
||||
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
|
||||
vec3 fog_color = mix(DARK_OVERLAY, zone_tint, deep_factor);
|
||||
```
|
||||
|
||||
**Note:** This is the architectural skeleton. The actual shader will need:
|
||||
- A proper noise function or noise texture uniform (Godot's `NoiseTexture2D` resource)
|
||||
- Correct world-to-UV coordinate mapping using `SCREEN_UV`, `CANVAS_MATRIX`, or vertex-passed world coords
|
||||
- The gradient edge for Layer 1 should span 6-8 sim tiles (D-059/D-066)
|
||||
- Layer 4 wireframe can use `mod()` on world coords for grid lines
|
||||
**Zone temperature palette (D-046):**
|
||||
- `hub` / `workplace`: `#1a1f2e` — cool blue-dark (institutional/terminal)
|
||||
- `bar`: `#2a1f15` — warm amber-dark (social/inhabited)
|
||||
- `corridor`: `#1a1a1a` — neutral dark (transitional/maintenance)
|
||||
|
||||
### Coordinate Mapping
|
||||
|
||||
@@ -227,13 +200,14 @@ Fog entities are NOT shader effects — they're GDScript-spawned sprites under `
|
||||
|
||||
```
|
||||
Per tick (in _process or on snapshot signal):
|
||||
1. FogState.update_visibility(GameState.visible_positions, GameState.visibility_sectors)
|
||||
→ Write visibility_image, upload to visibility_texture
|
||||
2. FogState.update_exploration(GameState.visible_positions)
|
||||
→ Mark visible tiles as explored, apply decay to non-visible explored tiles
|
||||
→ Upload to exploration_texture
|
||||
3. FogOverlay._process():
|
||||
→ Update shader uniforms (visibility_tex, exploration_tex, time, player_pos)
|
||||
1. FogState.update_from_state()
|
||||
→ Grow bounds if new tiles visible
|
||||
→ Write visibility from GameState.visible_positions (forward cone)
|
||||
→ Decay exploration: tiles leaving LOS → EXP_EXPLORED (128)
|
||||
→ Write zone tint from visible_tiles[].zone_id
|
||||
→ Upload changed textures
|
||||
2. FogOverlay._process():
|
||||
→ Update shader uniforms (visibility_tex, exploration_tex, zone_tint_tex, time)
|
||||
→ Update fog entity positions/states from ObserverSnapshot fog entity data
|
||||
```
|
||||
|
||||
@@ -253,7 +227,7 @@ Per tick (in _process or on snapshot signal):
|
||||
|------|------|---------|
|
||||
| `client/scripts/autoloads/fog_state.gd` | Autoload | Fog texture management, exploration persistence |
|
||||
| `client/scripts/rendering/fog_shader.gd` | Script | FogOverlay node controller, shader uniform updates |
|
||||
| `client/shaders/fog.gdshader` | Shader | Fragment shader for 5-layer fog |
|
||||
| `client/shaders/fog.gdshader` | Shader | Fragment shader for 3-state fog (clear / explored / unexplored) |
|
||||
| `client/scenes/fog_sound_ping.tscn` | Scene | Sound ping rings (deferred to Sprint 7+, #431) |
|
||||
| `client/scenes/fog_entity_ghost.tscn` | Scene | Recognized entity ghost (deferred to Sprint 7+, #431) |
|
||||
| `client/scenes/fog_entity_blob.tscn` | Scene | Unrecognized entity blob (deferred to Sprint 7+, #431) |
|
||||
@@ -277,11 +251,11 @@ Per tick (in _process or on snapshot signal):
|
||||
1. **Start with the shader.** Get a basic 2-layer shader working (clear vs opaque) on a ColorRect, then incrementally add layers.
|
||||
2. **Coordinate mapping is the hardest part.** Getting screen pixels → world tiles → texture UVs correct requires careful math. Test with a known map layout.
|
||||
3. **Use Godot's NoiseTexture2D** resource for the Perlin noise rather than computing it in the shader. Pass it as a uniform. Scroll the UV offset with TIME for animation.
|
||||
4. **The gradient edge** (Layer 1, 6-8 sim tiles) is the most visible quality differentiator. Use `smoothstep()` with the distance from the nearest non-visible tile. This may require encoding distance-to-edge in the visibility texture rather than binary 0/255.
|
||||
5. **Fog entities are Sprint 7+ (#431).** For this sprint, just get the 5-layer fog shader working. The FogEntities node can be empty.
|
||||
4. **The gradient edge** (Layer 1, 3-4 tile radius via 7x7 Gaussian) is the most visible quality differentiator. Use `smoothstep()` with the distance from the nearest non-visible tile. This may require encoding distance-to-edge in the visibility texture rather than binary 0/255.
|
||||
5. **Fog entities are Sprint 7+ (#431).** For this sprint, just get the 3-state fog shader working. The FogEntities node can be empty.
|
||||
6. **Test with the existing sim_bridge test mode** — it provides a visible_positions Dictionary with a 4-tile radius and Bresenham LOS. Good enough to validate the shader.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Q: How does the "maps app" data reach the client?** Layer 4 (unexplored + maps) needs to know which unexplored tiles the character's insert has map data for. This likely requires a new field in ObserverSnapshot (e.g., `mapped_tiles`). For Sprint 6, treat all explored tiles as "has maps" and all unexplored as "no maps" (layers 3 and 5 only, skip layer 4). Layer 4 is a v0.1.2+ feature.
|
||||
- **Q: Zone temperature tints — where do they come from?** Currently no per-tile zone data in the snapshot. For Sprint 6, use a hardcoded default (neutral dark). Zone tints require server-side zone metadata.
|
||||
- **Resolved (Sprint 22, #563):** Zone temperature tints come from `zone_id` field on `visible_tiles[]` in `ObserverSnapshot` (D-077). `fog_state.gd` maps zone_id strings to `ZONE_TINTS` color dictionary. Without zone metadata, defaults to neutral dark `#1a1a1a`.
|
||||
|
||||
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 68 KiB |