chore: resolve CHANGELOG.md merge conflict from main
This commit is contained in:
@@ -288,6 +288,17 @@ Task(
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `{team}`
|
||||
|
||||
RULES:
|
||||
- GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead.
|
||||
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
db/connectors/ticket show 528
|
||||
db/connectors/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
|
||||
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
||||
2. Read the decision files referenced in the briefing.
|
||||
3. Check TaskList for available work.
|
||||
|
||||
@@ -9,6 +9,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
### Added
|
||||
- Monologue display system visual spec — typography, positioning, stacking, priority, fade animation, character color differentiation, 80-char line constraint (#315)
|
||||
|
||||
## [v0.1.13] — 2026-02-20
|
||||
|
||||
### Added
|
||||
- D-078: Overheard NPC conversation — passive dialogue panel with server-authoritative stochastic word occlusion
|
||||
- Sprint 14 "Live" briefings — 22 tickets across server (7), client (3), copy (6), visual (6)
|
||||
|
||||
## [v0.1.12] — 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -35,7 +35,7 @@ db/
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index and query examples
|
||||
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
|
||||
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
|
||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
|
||||
process.md # D-004, D-021, D-022
|
||||
@@ -132,6 +132,12 @@ tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open
|
||||
Key rules:
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
|
||||
```bash
|
||||
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
|
||||
# Step 2: Post via cat
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
|
||||
```
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://caeuztw9ah5h5l"
|
||||
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_npc_murmur.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,43 @@
|
||||
[gd_resource type="AudioBusLayout" format=3]
|
||||
|
||||
; D-068: 5-bus audio architecture — Music, Ambient, WorldSFX, PlayerActions, UISounds.
|
||||
; All buses route to Master. Volumes managed at runtime by AudioManager autoload.
|
||||
; AudioManager._setup_buses() creates any missing buses on startup (no-op if present).
|
||||
|
||||
[resource]
|
||||
bus/0/name = "Master"
|
||||
bus/0/solo = false
|
||||
bus/0/mute = false
|
||||
bus/0/bypass_fx = false
|
||||
bus/0/volume_db = 0.0
|
||||
bus/0/send = &""
|
||||
bus/1/name = "Music"
|
||||
bus/1/solo = false
|
||||
bus/1/mute = false
|
||||
bus/1/bypass_fx = false
|
||||
bus/1/volume_db = 0.0
|
||||
bus/1/send = &"Master"
|
||||
bus/2/name = "Ambient"
|
||||
bus/2/solo = false
|
||||
bus/2/mute = false
|
||||
bus/2/bypass_fx = false
|
||||
bus/2/volume_db = 0.0
|
||||
bus/2/send = &"Master"
|
||||
bus/3/name = "WorldSFX"
|
||||
bus/3/solo = false
|
||||
bus/3/mute = false
|
||||
bus/3/bypass_fx = false
|
||||
bus/3/volume_db = 0.0
|
||||
bus/3/send = &"Master"
|
||||
bus/4/name = "PlayerActions"
|
||||
bus/4/solo = false
|
||||
bus/4/mute = false
|
||||
bus/4/bypass_fx = false
|
||||
bus/4/volume_db = 0.0
|
||||
bus/4/send = &"Master"
|
||||
bus/5/name = "UISounds"
|
||||
bus/5/solo = false
|
||||
bus/5/mute = false
|
||||
bus/5/bypass_fx = false
|
||||
bus/5/volume_db = 0.0
|
||||
bus/5/send = &"Master"
|
||||
@@ -24,6 +24,10 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
|
||||
[audio]
|
||||
|
||||
buses/default_bus_layout="res://default_bus_layout.tres"
|
||||
|
||||
[gui]
|
||||
|
||||
theme/custom="res://assets/theme/game_theme.tres"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=21 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=22 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -20,6 +20,7 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
|
||||
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -162,3 +163,6 @@ layer = 30
|
||||
|
||||
; #495: WRONG button (F12) — bug report capture dialog
|
||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
|
||||
|
||||
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
|
||||
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
|
||||
|
||||
@@ -41,6 +41,23 @@ const DIP_SPECS := {
|
||||
# Low-pass filter default cutoff — effectively bypassed at this value.
|
||||
const FILTER_CUTOFF_DEFAULT := 20500.0
|
||||
|
||||
# --- D-073: Zone crossfade constants ---
|
||||
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
||||
|
||||
# Maps server zone_id strings to ambient asset keys (filenames in res://audio/).
|
||||
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
|
||||
# they are the same location type, so hub→workplace transition is a same-asset no-op
|
||||
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
|
||||
# D-038's "amb_workplace_layer" to "amb_hub_layer" for v0.1.
|
||||
# Note: amb_station_base (D-038 global base hum) plays globally via play_loop()
|
||||
# at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
|
||||
const ZONE_ASSETS: Dictionary = {
|
||||
"hub": "amb_hub_layer",
|
||||
"workplace": "amb_hub_layer",
|
||||
"bar": "amb_bar_layer",
|
||||
"corridor": "amb_corridor_layer",
|
||||
}
|
||||
|
||||
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
|
||||
var _registry: Dictionary = {}
|
||||
|
||||
@@ -57,12 +74,19 @@ var _ambient_filter: AudioEffectLowPassFilter = null
|
||||
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
|
||||
var _ambient_players: Dictionary = {}
|
||||
|
||||
# D-073: Zone crossfade state
|
||||
var _current_zone_id: String = ""
|
||||
var _zone_tweens: Array = []
|
||||
|
||||
signal dip_changed(profile: String)
|
||||
|
||||
|
||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_buses()
|
||||
_scan_registry()
|
||||
_load_prefs()
|
||||
|
||||
|
||||
# --- Bus setup ---
|
||||
@@ -110,6 +134,7 @@ func _scan_dir(path: String) -> void:
|
||||
if stream:
|
||||
_registry[file_name.get_basename()] = stream
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func has_asset(asset_key: String) -> bool:
|
||||
@@ -173,9 +198,8 @@ func stop_all_loops() -> void:
|
||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||
# Maps server-sent sound event_type strings to audio asset keys.
|
||||
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
|
||||
# Audio assets per D-038: footstep variants (walk / run).
|
||||
# No asset for Voice events in v0.1 — play_sound_event no-ops gracefully
|
||||
# (D-038: "renders as audio if asset exists, or visual indicator + monologue if not").
|
||||
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||
"Footstep": "sfx_footstep_metal_walk",
|
||||
"FootstepWalk": "sfx_footstep_metal_walk",
|
||||
@@ -304,19 +328,85 @@ func set_volume(bus: String, volume_db: float) -> void:
|
||||
var buses: Dictionary = spec.get("buses", {})
|
||||
var offset_db: float = buses.get(bus, 0.0)
|
||||
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
|
||||
_save_prefs()
|
||||
|
||||
|
||||
func get_volume(bus: String) -> float:
|
||||
return _bus_volumes.get(bus, 0.0)
|
||||
|
||||
|
||||
# --- Zone crossfade (D-073 stub) ---
|
||||
# --- Volume persistence (user://audio_prefs.cfg) ---
|
||||
|
||||
func _load_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(PREFS_PATH) != OK:
|
||||
return
|
||||
for bus_name in BUSES:
|
||||
if cfg.has_section_key("audio", bus_name):
|
||||
var db: float = cfg.get_value("audio", bus_name, 0.0)
|
||||
# Apply directly: bypass _save_prefs() on initial load.
|
||||
_bus_volumes[bus_name] = db
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx >= 0:
|
||||
AudioServer.set_bus_volume_db(idx, db)
|
||||
|
||||
|
||||
func _save_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
for bus_name in BUSES:
|
||||
cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0))
|
||||
var err := cfg.save(PREFS_PATH)
|
||||
if err != OK:
|
||||
push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err])
|
||||
|
||||
|
||||
# --- Zone crossfade (D-073) ---
|
||||
|
||||
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
|
||||
## Full crossfade implementation deferred to Sprint 9+ (D-073).
|
||||
## Stub exists so server integration can call it without conditional checks.
|
||||
func set_zone(_zone_id: String) -> void:
|
||||
pass
|
||||
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
|
||||
## Interruptible — mid-crossfade zone change tweens from current position.
|
||||
## No-op if assets absent (D-038) or same zone.
|
||||
func set_zone(zone_id: String) -> void:
|
||||
if zone_id == _current_zone_id:
|
||||
return
|
||||
var new_asset: String = ZONE_ASSETS.get(zone_id, "")
|
||||
var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "")
|
||||
_current_zone_id = zone_id
|
||||
_kill_zone_tweens()
|
||||
|
||||
# Fade out old ambient layer (if different asset from incoming zone)
|
||||
if not old_asset.is_empty() and old_asset != new_asset:
|
||||
if _ambient_players.has(old_asset):
|
||||
var old_player: AudioStreamPlayer = _ambient_players[old_asset]
|
||||
if is_instance_valid(old_player):
|
||||
var tween := create_tween()
|
||||
tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION)
|
||||
tween.tween_callback(stop_loop.bind(old_asset))
|
||||
_zone_tweens.append(tween)
|
||||
|
||||
# Fade in new ambient layer
|
||||
if not new_asset.is_empty():
|
||||
if _ambient_players.has(new_asset):
|
||||
# Already playing (interrupted reverse crossfade) — tween from current volume
|
||||
var existing: AudioStreamPlayer = _ambient_players[new_asset]
|
||||
if is_instance_valid(existing):
|
||||
var tween := create_tween()
|
||||
tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION)
|
||||
_zone_tweens.append(tween)
|
||||
elif has_asset(new_asset):
|
||||
var new_player := play_loop(new_asset, BUS_AMBIENT)
|
||||
if new_player:
|
||||
new_player.volume_db = -80.0
|
||||
var tween := create_tween()
|
||||
tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION)
|
||||
_zone_tweens.append(tween)
|
||||
|
||||
|
||||
func _kill_zone_tweens() -> void:
|
||||
for tween in _zone_tweens:
|
||||
if tween != null and tween.is_valid():
|
||||
tween.kill()
|
||||
_zone_tweens.clear()
|
||||
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
@@ -62,6 +62,17 @@ var medium_sound_events: Array = []
|
||||
# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd.
|
||||
var close_sound_events: Array = []
|
||||
|
||||
# D-071 (#530): Consecutive ticks without player position change.
|
||||
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
|
||||
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
||||
var stationary_ticks: int = 0
|
||||
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
||||
|
||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
|
||||
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
|
||||
var current_zone_id: String = ""
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
current_snapshot = snapshot
|
||||
|
||||
@@ -83,6 +94,14 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
visible_entities.size()])
|
||||
|
||||
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
|
||||
# Compares current player_position against previous snapshot's position.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
_prev_player_position = player_position
|
||||
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
||||
if snapshot.has("tiles"):
|
||||
visible_tiles = snapshot.tiles
|
||||
@@ -183,6 +202,17 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
||||
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
||||
# positional indexing — use the same tile iteration below instead.
|
||||
current_zone_id = ""
|
||||
var _px := int(player_position.x)
|
||||
var _py := int(player_position.y)
|
||||
for _ztile in visible_tiles:
|
||||
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
||||
current_zone_id = _ztile.get("zone_id", "")
|
||||
break
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
|
||||
@@ -15,6 +15,7 @@ extends Node2D
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _camera_anchored: bool = false
|
||||
@@ -24,6 +25,9 @@ var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have alrea
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
||||
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||
|
||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
||||
|
||||
func _ready() -> void:
|
||||
print("The Settled Reach — client initialized")
|
||||
@@ -128,6 +132,13 @@ func _process(_delta: float) -> void:
|
||||
# D-018 #125: Play close-range sound events via positional 2D audio
|
||||
_play_close_sound_events()
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
|
||||
_update_zone()
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
|
||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
||||
_update_listening_focus()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
@@ -162,6 +173,14 @@ func _process(_delta: float) -> void:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
continue
|
||||
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||
if input.action == InputMapper.Action.OPEN_MENU:
|
||||
if settings_dialog:
|
||||
if settings_dialog.is_open():
|
||||
settings_dialog.close()
|
||||
else:
|
||||
settings_dialog.open()
|
||||
continue
|
||||
if input.action == InputMapper.Action.INTERACT:
|
||||
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
|
||||
var target_id: int = -1
|
||||
@@ -235,6 +254,30 @@ func _play_recognition_chimes() -> void:
|
||||
_known_recognition_ids.erase(eid)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||
# (extracted in apply_snapshot(), server-authoritative per D-020).
|
||||
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
|
||||
func _update_zone() -> void:
|
||||
var zone := GameState.current_zone_id
|
||||
if zone != _current_zone:
|
||||
_current_zone = zone
|
||||
AudioManager.set_zone(zone)
|
||||
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
|
||||
# Uses AudioManager.get_active_dip() as single source of truth (no separate flag).
|
||||
# Only activates when no other dip (dialogue/confrontation) is running.
|
||||
# Only deactivates its own dip — never touches dialogue/confrontation.
|
||||
# D-070: no UI indicator — the boost is "felt, not computed."
|
||||
func _update_listening_focus() -> void:
|
||||
var current_dip := AudioManager.get_active_dip()
|
||||
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
|
||||
if threshold_met and current_dip == "":
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
elif not threshold_met and current_dip == "listening_focus":
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# Consume-once per tick: show monologue text, then clear.
|
||||
# Tick guard prevents re-triggering when the same tick is polled multiple
|
||||
# times (client FPS > sim tick rate).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -15,10 +15,16 @@ extends GdUnitTestSuite
|
||||
|
||||
func before_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
for bus in AudioManager.BUSES:
|
||||
AudioManager.set_volume(bus, 0.0)
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -171,22 +177,25 @@ func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void:
|
||||
|
||||
func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void:
|
||||
## apply_dip() must emit dip_changed(profile) synchronously.
|
||||
var received_profile := ""
|
||||
var conn := func(p: String) -> void: received_profile = p
|
||||
## Array wrapper used for lambda capture — GDScript 4 captures String locals by value,
|
||||
## so a mutable reference type is required to observe signal argument inside the closure.
|
||||
var received := [""]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received_profile).is_equal("dialogue")
|
||||
assert_that(received[0]).is_equal("dialogue")
|
||||
|
||||
func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void:
|
||||
## clear_dip() must emit dip_changed("") to signal audio restored.
|
||||
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
var received_profile := "sentinel"
|
||||
var conn := func(p: String) -> void: received_profile = p
|
||||
var received := ["sentinel"]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received_profile).is_equal("")
|
||||
assert_that(received[0]).is_equal("")
|
||||
|
||||
func test_audio_manager_apply_dip_interrupts_previous() -> void:
|
||||
## Switching profiles mid-dip: active profile must update to the new one.
|
||||
@@ -196,13 +205,14 @@ func test_audio_manager_apply_dip_interrupts_previous() -> void:
|
||||
|
||||
func test_audio_manager_dip_changed_fires_on_profile_switch() -> void:
|
||||
## Switching from dialogue to confrontation emits dip_changed("confrontation").
|
||||
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
var received_profile := ""
|
||||
var conn := func(p: String) -> void: received_profile = p
|
||||
var received := [""]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.apply_dip("confrontation")
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received_profile).is_equal("confrontation")
|
||||
assert_that(received[0]).is_equal("confrontation")
|
||||
|
||||
func test_audio_manager_has_asset_false_for_unknown_key() -> void:
|
||||
## has_asset() must return false for a key that was never registered.
|
||||
@@ -291,3 +301,57 @@ func test_audio_manager_play_sound_event_noop_for_empty_type() -> void:
|
||||
## #125: Empty event type string must be a no-op.
|
||||
AudioManager.play_sound_event("", Vector2(5.0, 5.0))
|
||||
# No assertion needed — absence of crash is the test.
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 6: D-071 (#530) — Stationary tick tracking for ListeningFocus
|
||||
# ==============================================================================
|
||||
|
||||
func test_d071_stationary_ticks_increments_when_position_unchanged() -> void:
|
||||
## D-071: stationary_ticks must increment on each snapshot where player doesn't move.
|
||||
var player_entity := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [player_entity]})
|
||||
GameState.apply_snapshot({"tick": 2, "entities": [player_entity]})
|
||||
GameState.apply_snapshot({"tick": 3, "entities": [player_entity]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(2) # 2 ticks of no movement (tick 2 and 3)
|
||||
|
||||
func test_d071_stationary_ticks_resets_on_movement() -> void:
|
||||
## D-071: stationary_ticks must reset to 0 when the player position changes.
|
||||
var pos_a := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
var pos_b := {"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [pos_a]})
|
||||
GameState.apply_snapshot({"tick": 2, "entities": [pos_a]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(1)
|
||||
GameState.apply_snapshot({"tick": 3, "entities": [pos_b]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(0)
|
||||
|
||||
func test_d071_stationary_ticks_reaches_threshold() -> void:
|
||||
## D-071: stationary_ticks must be able to reach 30+ for ListeningFocus activation.
|
||||
var player_entity := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
for tick in range(31):
|
||||
GameState.apply_snapshot({"tick": tick, "entities": [player_entity]})
|
||||
assert_that(GameState.stationary_ticks >= 30).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 7: D-069 (#530) — Dialogue dip profile interaction
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_dialogue_dip_overridden_by_confrontation() -> void:
|
||||
## D-069: Confrontation dip must override dialogue dip (apply_dip interrupts).
|
||||
AudioManager.apply_dip("dialogue")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
AudioManager.apply_dip("confrontation")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
|
||||
|
||||
func test_d069_listening_focus_cleared_by_dialogue() -> void:
|
||||
## D-069: Dialogue dip must override listening_focus (higher priority focus state).
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("listening_focus")
|
||||
AudioManager.apply_dip("dialogue")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
|
||||
func test_d069_clear_dip_noop_when_empty() -> void:
|
||||
## D-069: clear_dip() when no dip active must be a safe no-op.
|
||||
AudioManager.clear_dip() # Should not crash
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
## Test suite for Sprint 13 audio tickets (D-067, D-068, D-069, D-071, D-072, D-073).
|
||||
##
|
||||
## Spec refs:
|
||||
## D-067: Recognition chime fires at ONSET of cognitive delay, not at completion.
|
||||
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds).
|
||||
## D-069: Audio dip profiles — timing values, dB offsets, interruptibility.
|
||||
## D-071: ListeningFocus boost (30+ tick gate, caller's responsibility).
|
||||
## D-072: Universal NPC conversation murmur on WorldSFX — no zone-specific variants.
|
||||
## D-073: Zone crossfade — hard boundary trigger, 1.5-2s tween, interruptible.
|
||||
##
|
||||
## Tickets covered:
|
||||
## #529 — Zone crossfade implementation (set_zone body + tween timing)
|
||||
## #530 — Dip profile call sites (dialogue/confrontation/ListeningFocus wiring)
|
||||
## #531 — Recognition chime fires at cognitive delay onset
|
||||
## #533 — NPC conversation murmur wired to WorldSFX bus
|
||||
##
|
||||
## Test layers:
|
||||
## 1. Zone crossfade spec and API (D-073 / #529)
|
||||
## 2. D-069 dip timing and dB spec values (supplementing test_audio_bus_routing)
|
||||
## 3. Volume slider proportional dip (D-068 / D-069)
|
||||
## 4. Dip call site wiring via GameState snapshot (D-069 / D-070 / #530)
|
||||
## 5. Recognition chime onset verification (D-067 / #531)
|
||||
## 6. NPC murmur routing (D-072 / #533)
|
||||
## 7. AudioManager no-op fallback sanity (D-068 / D-038)
|
||||
class_name TestAudioSprint13
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.stop_all_loops()
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_MUSIC, 0.0)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 1: Zone Crossfade — D-073 / #529
|
||||
# set_zone() stub exists now; full implementation lands in #529.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d073_set_zone_method_exists() -> void:
|
||||
## D-073 / #529: AudioManager must expose set_zone(zone_id: String).
|
||||
assert_that(AudioManager.has_method("set_zone")).is_true()
|
||||
|
||||
|
||||
func test_d073_set_zone_hub_does_not_crash() -> void:
|
||||
## D-073: set_zone("hub") is a safe call. No crash even before #529 implementation.
|
||||
AudioManager.set_zone("hub")
|
||||
|
||||
|
||||
func test_d073_set_zone_bar_does_not_crash() -> void:
|
||||
## D-073: set_zone("bar") is a safe call.
|
||||
AudioManager.set_zone("bar")
|
||||
|
||||
|
||||
func test_d073_set_zone_corridor_does_not_crash() -> void:
|
||||
## D-073: set_zone("corridor") is a safe call.
|
||||
AudioManager.set_zone("corridor")
|
||||
|
||||
|
||||
func test_d073_set_zone_empty_string_does_not_crash() -> void:
|
||||
## D-073: set_zone("") edge case — no zone ID. Must not crash.
|
||||
AudioManager.set_zone("")
|
||||
|
||||
|
||||
func test_d073_set_zone_unknown_zone_does_not_crash() -> void:
|
||||
## D-073: Unmapped zone ID (no matching asset) — no crash, graceful no-op.
|
||||
AudioManager.set_zone("nonexistent_zone_xyz")
|
||||
|
||||
|
||||
func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief.
|
||||
## hub/workplace → amb_hub_layer (must match filename stem in res://audio/).
|
||||
## Test verifies naming convention is documentable. Activates once #529 adds the map.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("hub")).is_true()
|
||||
assert_that(zone_assets["hub"]).is_equal("amb_hub_layer")
|
||||
|
||||
|
||||
func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: bar → amb_bar_layer
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("bar")).is_true()
|
||||
assert_that(zone_assets["bar"]).is_equal("amb_bar_layer")
|
||||
|
||||
|
||||
func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: smuggling corridor → amb_corridor_layer
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("corridor")).is_true()
|
||||
assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer")
|
||||
|
||||
|
||||
func test_d073_game_state_extracts_zone_id_from_player_tile() -> void:
|
||||
## D-073 / Tyre review: zone_id is extracted in GameState.apply_snapshot() as
|
||||
## a first-class field (like player_facing, player_stance), avoiding O(N) tile
|
||||
## scan in main.gd per D-020 server-authoritative state.
|
||||
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [player],
|
||||
"tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "bar"},
|
||||
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "hub"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).is_equal("bar")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d073_game_state_zone_id_empty_when_field_absent() -> void:
|
||||
## D-073: Defensive — zone_id missing from tile data (server hasn't shipped OQ-09).
|
||||
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [player],
|
||||
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor"}],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).is_equal("")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void:
|
||||
## D-073: Crossfade tween duration must be 1.5-2.0s.
|
||||
## Activates once #529 defines the duration constant.
|
||||
if not "CROSSFADE_DURATION" in AudioManager:
|
||||
push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test")
|
||||
return
|
||||
var duration: float = AudioManager.CROSSFADE_DURATION
|
||||
assert_that(duration >= 1.5 and duration <= 2.0).is_true()
|
||||
|
||||
|
||||
func test_d073_set_zone_same_zone_repeated_is_noop() -> void:
|
||||
## D-073: Crossing back to the current zone should not restart a crossfade.
|
||||
## (No audio pops when zone boundary is ambiguous.) Activates post-#529.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test")
|
||||
return
|
||||
AudioManager.set_zone("hub")
|
||||
AudioManager.set_zone("hub")
|
||||
## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens).
|
||||
## Stub: assert no crash and ambient_players size is 0 or 1, not 2.
|
||||
assert_that(AudioManager._ambient_players.size() <= 1).is_true()
|
||||
|
||||
|
||||
func test_d073_rapid_zone_crossing_interruptible() -> void:
|
||||
## D-073: Rapid back-and-forth zone crossing (interruptible crossfade).
|
||||
## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate
|
||||
## volume — new tween starts from current position. No stacked tweens, no crash.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test")
|
||||
return
|
||||
# Cross hub → bar → hub rapidly (simulates player walking back and forth)
|
||||
AudioManager.set_zone("hub")
|
||||
AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween
|
||||
AudioManager.set_zone("hub") # Interrupts bar fade-in mid-tween
|
||||
AudioManager.set_zone("corridor") # Interrupts hub fade-in mid-tween
|
||||
## After rapid crossing: at most 2 ambient players (outgoing fade-out + incoming fade-in).
|
||||
## No stacked tweens — _kill_zone_tweens clears previous tweens each time.
|
||||
assert_that(AudioManager._ambient_players.size() <= 2).is_true()
|
||||
## Zone tweens array should only contain active tweens from the last set_zone call.
|
||||
assert_that(AudioManager._zone_tweens.size() <= 2).is_true()
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
func test_d068_load_prefs_persistence_roundtrip() -> void:
|
||||
## #528: Volume persistence — save/load roundtrip via ConfigFile.
|
||||
## Sets non-default volumes, saves, reloads, and verifies values match.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -8.5)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -12.0)
|
||||
## _save_prefs() fires inside set_volume() — prefs file is written.
|
||||
## Reload prefs by calling _load_prefs() directly.
|
||||
AudioManager._load_prefs()
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-8.5, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-12.0, 0.01)
|
||||
## Reset to defaults for subsequent tests.
|
||||
for bus in AudioManager.BUSES:
|
||||
AudioManager.set_volume(bus, 0.0)
|
||||
|
||||
|
||||
func test_d068_load_prefs_handles_missing_config_gracefully() -> void:
|
||||
## #528: _load_prefs() with a missing/corrupted config file must not crash.
|
||||
## The config path is user://audio_prefs.cfg — if absent, _load_prefs returns early.
|
||||
## This test documents the graceful fallback behavior.
|
||||
AudioManager._load_prefs()
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2: D-069 Dip Timing and dB Spec Values
|
||||
# These tests supplement test_audio_bus_routing.gd with timing and dB precision.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_dialogue_ease_in_is_300ms() -> void:
|
||||
## D-069: Dialogue dip ease-in = 300ms (0.3s).
|
||||
var ease_in: float = AudioManager.DIP_SPECS["dialogue"]["ease_in"]
|
||||
assert_that(ease_in).is_equal_approx(0.3, 0.05)
|
||||
|
||||
|
||||
func test_d069_dialogue_ease_out_is_500ms() -> void:
|
||||
## D-069: Dialogue dip ease-out = 500ms (0.5s).
|
||||
var ease_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
|
||||
assert_that(ease_out).is_equal_approx(0.5, 0.05)
|
||||
|
||||
|
||||
func test_d069_confrontation_ease_in_is_500ms() -> void:
|
||||
## D-069: Confrontation dip ease-in = 500ms (0.5s).
|
||||
var ease_in: float = AudioManager.DIP_SPECS["confrontation"]["ease_in"]
|
||||
assert_that(ease_in).is_equal_approx(0.5, 0.05)
|
||||
|
||||
|
||||
func test_d069_confrontation_ease_out_is_1000ms() -> void:
|
||||
## D-069: Confrontation dip ease-out = 1000ms (1.0s) — longer exit for immersion.
|
||||
var ease_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
|
||||
assert_that(ease_out).is_equal_approx(1.0, 0.05)
|
||||
|
||||
|
||||
func test_d069_dialogue_ambient_dip_within_6_to_8_db() -> void:
|
||||
## D-069: Dialogue dip: Ambient -6 to -8dB. Mid-range value (-7) used.
|
||||
var dip: float = AudioManager.DIP_SPECS["dialogue"]["buses"]["Ambient"]
|
||||
assert_that(dip >= -8.0 and dip <= -6.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_ambient_dip_within_10_to_12_db() -> void:
|
||||
## D-069: Confrontation dip: Ambient -10 to -12dB. Mid-range value (-11) used.
|
||||
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["Ambient"]
|
||||
assert_that(dip >= -12.0 and dip <= -10.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_world_sfx_dip_within_4_to_6_db() -> void:
|
||||
## D-069: Confrontation dip: WorldSFX -4 to -6dB (graduated — loud events break through).
|
||||
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["WorldSFX"]
|
||||
assert_that(dip >= -6.0 and dip <= -4.0).is_true()
|
||||
|
||||
|
||||
func test_d069_listening_focus_world_sfx_boost_within_2_to_3_db() -> void:
|
||||
## D-069 / D-071: ListeningFocus boosts WorldSFX +2 to +3dB (eavesdrop bonus).
|
||||
var boost: float = AudioManager.DIP_SPECS["listening_focus"]["buses"]["WorldSFX"]
|
||||
assert_that(boost >= 2.0 and boost <= 3.0).is_true()
|
||||
|
||||
|
||||
func test_d069_dialogue_spec_only_affects_ambient_bus() -> void:
|
||||
## D-069: Dialogue dip touches ONLY Ambient. WorldSFX, PlayerActions, UISounds, Music
|
||||
## must NOT appear in the spec — world events remain audible during conversation.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_true()
|
||||
assert_that(buses.has("WorldSFX")).is_false()
|
||||
assert_that(buses.has("PlayerActions")).is_false()
|
||||
assert_that(buses.has("UISounds")).is_false()
|
||||
assert_that(buses.has("Music")).is_false()
|
||||
|
||||
|
||||
func test_d069_confrontation_spec_does_not_affect_player_actions() -> void:
|
||||
## D-069: Confrontation dip leaves PlayerActions at 0 — player sounds are NOT muffled.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("PlayerActions")).is_false()
|
||||
|
||||
|
||||
func test_d069_confrontation_spec_does_not_affect_ui_sounds() -> void:
|
||||
## D-069: Confrontation dip leaves UISounds at 0 — chimes and UI remain audible.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("UISounds")).is_false()
|
||||
|
||||
|
||||
func test_d069_listening_focus_spec_does_not_affect_ambient() -> void:
|
||||
## D-071: ListeningFocus boost is ONLY on WorldSFX. Ambient is NOT modified.
|
||||
## D-071: Eavesdropping requires MORE ambient awareness, not less — no ambient dip.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_false()
|
||||
|
||||
|
||||
func test_d069_filter_cutoff_default_is_approx_20khz() -> void:
|
||||
## D-069: Default low-pass filter cutoff is ~20kHz — effectively bypassed.
|
||||
## Confrontation dip sweeps it down to 800Hz. Default must be >= 20000Hz.
|
||||
assert_that(AudioManager.FILTER_CUTOFF_DEFAULT >= 20000.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_filter_hz_is_800hz() -> void:
|
||||
## D-069: Confrontation sweeps low-pass filter to ~800Hz for muffled feel (D-070).
|
||||
var filter_hz: float = AudioManager.DIP_SPECS["confrontation"]["filter_hz"]
|
||||
assert_that(filter_hz).is_equal_approx(800.0, 50.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 3: Volume Slider Proportional Dip — D-068 / D-069
|
||||
# ==============================================================================
|
||||
|
||||
func test_d068_set_volume_get_volume_roundtrip() -> void:
|
||||
## D-068: set_volume / get_volume roundtrip preserves the slider value.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -6.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-6.0, 0.01)
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
|
||||
|
||||
func test_d068_set_volume_persists_across_all_buses() -> void:
|
||||
## D-068: Each of the 5 buses has an independent volume setting.
|
||||
AudioManager.set_volume(AudioManager.BUS_MUSIC, -10.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, -2.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -1.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_MUSIC)).is_equal_approx(-10.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_PLAYER_ACTIONS)).is_equal_approx(-2.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-1.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_get_volume_returns_slider_base_not_effective_volume() -> void:
|
||||
## D-069: get_volume() always returns the player slider setting (base).
|
||||
## The effective AudioServer volume during a dip = base + offset_db.
|
||||
## Callers storing the slider value must always read get_volume(), not AudioServer directly.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
## Even during dip, get_volume returns the base (not base + dip offset).
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(0.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_set_volume_during_active_dip_updates_base() -> void:
|
||||
## D-069: Changing slider mid-dip must update the base so the proportional
|
||||
## calculation uses the new slider value (not the pre-dip value).
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -3.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-3.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_clear_dip_does_not_change_stored_slider_value() -> void:
|
||||
## D-069: clear_dip() restores AudioServer volumes to base, but get_volume()
|
||||
## must still reflect the player slider setting (not the dipped value).
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.clear_dip()
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 4: Dip Call Site Wiring — D-069 / D-070 / D-071 / #530
|
||||
#
|
||||
# These tests verify GameState has the snapshot fields needed for #530 wiring.
|
||||
# The assertions on AudioManager dip activation are stubbed pending implementation.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_game_state_current_dialogue_field_exists() -> void:
|
||||
## #530 precondition: GameState.current_dialogue is the trigger for dialogue dip.
|
||||
## Field must exist so #530 wiring can check it.
|
||||
assert_that("current_dialogue" in GameState).is_true()
|
||||
|
||||
|
||||
func test_d069_snapshot_with_dialogue_sets_current_dialogue() -> void:
|
||||
## #530 precondition: apply_snapshot() with current_dialogue populates GameState correctly.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {
|
||||
"npc_name": "Kael", "npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around.", "options": [],
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_dialogue != null).is_true()
|
||||
assert_that(GameState.current_dialogue is Dictionary).is_true()
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d069_snapshot_without_dialogue_clears_current_dialogue() -> void:
|
||||
## #530 precondition: Snapshot without current_dialogue → current_dialogue is null.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.current_dialogue == null).is_true()
|
||||
|
||||
|
||||
func test_d069_dialogue_dip_wired_to_game_state_dialogue_activation() -> void:
|
||||
## D-069 / #530: When current_dialogue becomes active, apply_dip("dialogue") fires.
|
||||
## TODO(#530): Uncomment the assertion once call site is wired in sim_bridge/game_state.
|
||||
AudioManager.clear_dip()
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
## Precondition: dialogue IS active in GameState.
|
||||
assert_that(GameState.current_dialogue != null).is_true()
|
||||
## ASSERTION (activate once #530 is implemented):
|
||||
## assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
func test_d069_dialogue_dip_cleared_when_dialogue_ends() -> void:
|
||||
## D-069 / #530: When current_dialogue returns to null, clear_dip() fires.
|
||||
## TODO(#530): Uncomment the assertion once call site is wired.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
GameState.apply_snapshot({"tick": 2}) # dialogue ends
|
||||
## ASSERTION (activate once #530 is implemented):
|
||||
## assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
assert_that(GameState.current_dialogue == null).is_true()
|
||||
|
||||
|
||||
func test_d071_listening_focus_gate_is_30_ticks() -> void:
|
||||
## D-069 / D-071: ListeningFocus boost activates after 30+ stationary ticks.
|
||||
## The tick gate is the CALLER's responsibility per AudioManager comment.
|
||||
## This test documents the threshold so it doesn't silently drift.
|
||||
## Wiring via sim_bridge.gd tracking stationary_ticks lands in #530.
|
||||
const LISTENING_FOCUS_TICK_GATE := 30
|
||||
## Stub: verify the spec value is documented.
|
||||
assert_that(LISTENING_FOCUS_TICK_GATE).is_equal(30)
|
||||
|
||||
|
||||
func test_d070_no_ui_indicator_means_no_signal_named_confrontation_ui() -> void:
|
||||
## D-070: Confrontation muffling is felt, not announced. No UI indicator.
|
||||
## Verify AudioManager does not expose a confrontation_ui_shown signal.
|
||||
var signals: Array = AudioManager.get_signal_list().map(
|
||||
func(s: Dictionary) -> String: return s.name
|
||||
)
|
||||
assert_that(signals.has("confrontation_ui_shown")).is_false()
|
||||
assert_that(signals.has("listening_focus_shown")).is_false()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 5: Recognition Chime Onset — D-067 / #531
|
||||
#
|
||||
# Chime fires at ONSET of cognitive delay (when entity FIRST appears in
|
||||
# pending_recognitions), NOT at completion (when it leaves).
|
||||
# ==============================================================================
|
||||
|
||||
func test_d067_chime_recognition_constant_defined() -> void:
|
||||
## D-067 / D-038: AudioManager must expose CHIME_RECOGNITION asset key constant.
|
||||
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
|
||||
|
||||
|
||||
func test_d067_chime_recognition_matches_d038_asset_key() -> void:
|
||||
## D-067 / D-038: sfx_monologue_chime = "neural lattice firing" feel.
|
||||
## Key must match filename stem in res://audio/.
|
||||
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
|
||||
|
||||
|
||||
func test_d067_play_routes_to_ui_sounds_bus_by_default() -> void:
|
||||
## D-067: play(CHIME_RECOGNITION) routes to BUS_UI_SOUNDS by default.
|
||||
## Chime is a cognitive/UI signal, not a world sound — must NOT go on WorldSFX.
|
||||
## Verify play() default bus is UISounds (the chime caller uses the default).
|
||||
## Edge: BUS_WORLD_SFX and BUS_UI_SOUNDS must be distinct.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX).is_not_equal(AudioManager.BUS_UI_SOUNDS)
|
||||
|
||||
|
||||
func test_d067_play_chime_recognition_noop_when_asset_absent() -> void:
|
||||
## D-067 / D-038: play(CHIME_RECOGNITION) is a silent no-op if asset file is absent.
|
||||
## Client must not crash when audio branch has not yet provided the .ogg file.
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
## No assertion needed — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void:
|
||||
## D-067: "Onset" of cognitive delay = first frame an entity appears in
|
||||
## pending_recognitions, at remaining_ticks == total_delay_ticks.
|
||||
## This is the moment the chime must fire.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
var rec: Dictionary = GameState.pending_recognitions[0]
|
||||
## Onset condition: remaining == total (delay just started)
|
||||
assert_that(rec.remaining_ticks).is_equal(rec.total_delay_ticks)
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d067_completion_is_when_entity_absent_from_pending() -> void:
|
||||
## D-067: Recognition COMPLETES (blob transitions) when entity leaves pending_recognitions.
|
||||
## The chime must NOT fire at this point — it fired at onset.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 1, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
## Completion: entity removed from array
|
||||
GameState.apply_snapshot({"tick": 2, "pending_recognitions": []})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(0)
|
||||
## Chime state: AudioManager must not have a dip triggered by recognition.
|
||||
## (Chime is a play() call, not a dip — this verifies no side effects on dip state.)
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
|
||||
func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void:
|
||||
## D-067 / #531: The chime call site in fog_entities.gd / entity_renderer.gd
|
||||
## must be inside the "new entity" branch (not entity.has(eid)), NOT the cleanup loop.
|
||||
## This test verifies FogEntities correctly identifies the onset condition.
|
||||
## An entity with remaining_ticks == total_delay_ticks is a NEW entity entering delay.
|
||||
var onset_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6}
|
||||
var mid_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 3, "total_delay_ticks": 6}
|
||||
## Frame 1: entity APPEARS (onset — chime should fire here)
|
||||
GameState.apply_snapshot({"tick": 1, "pending_recognitions": [onset_tick]})
|
||||
assert_that(GameState.pending_recognitions[0].remaining_ticks
|
||||
== GameState.pending_recognitions[0].total_delay_ticks).is_true()
|
||||
## Frame 2: entity mid-progress (chime must NOT re-fire)
|
||||
GameState.apply_snapshot({"tick": 2, "pending_recognitions": [mid_tick]})
|
||||
assert_that(GameState.pending_recognitions[0].remaining_ticks).is_equal(3)
|
||||
## Frame 3: entity completes (chime must NOT fire)
|
||||
GameState.apply_snapshot({"tick": 3, "pending_recognitions": []})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_d067_chime_duration_spec_is_300_to_400ms() -> void:
|
||||
## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration.
|
||||
## This test documents the spec range so asset authoring can be validated.
|
||||
## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should
|
||||
## return a value in this range.
|
||||
const CHIME_MIN_DURATION := 0.3
|
||||
const CHIME_MAX_DURATION := 0.4
|
||||
if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION):
|
||||
push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test")
|
||||
return
|
||||
var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION)
|
||||
if stream == null:
|
||||
push_warning("TestAudioSprint13: could not retrieve chime stream from registry")
|
||||
return
|
||||
assert_that(stream.get_length() >= CHIME_MIN_DURATION
|
||||
and stream.get_length() <= CHIME_MAX_DURATION).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 6: NPC Murmur Routing — D-072 / #533
|
||||
# Single universal asset, WorldSFX bus, no zone-specific variants.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d072_world_sfx_bus_is_correct_for_murmur() -> void:
|
||||
## D-072 / #533: NPC murmur routes to WorldSFX bus per D-068 architecture.
|
||||
## BUS_WORLD_SFX must be "WorldSFX" — zone ambient conspicuousness is determined
|
||||
## by that bus's noise floor relative to the murmur volume.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
|
||||
|
||||
|
||||
func test_d072_play_at_method_accepts_bus_parameter() -> void:
|
||||
## D-072 / #533: play_at() must accept an optional bus string parameter.
|
||||
## Proximity murmur uses play_at(asset_key, world_pos, BUS_WORLD_SFX).
|
||||
assert_that(AudioManager.has_method("play_at")).is_true()
|
||||
|
||||
|
||||
func test_d072_play_at_noop_when_murmur_asset_absent() -> void:
|
||||
## D-072 / D-038: sfx_npc_murmur.ogg arrives from audio branch (#532).
|
||||
## Until then, play_at("sfx_npc_murmur", ...) must be a silent no-op.
|
||||
AudioManager.play_at("sfx_npc_murmur", Vector2(100.0, 100.0), AudioManager.BUS_WORLD_SFX)
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d072_play_noop_when_murmur_asset_absent() -> void:
|
||||
## D-072 / D-038: play("sfx_npc_murmur", BUS_WORLD_SFX) also no-ops gracefully.
|
||||
AudioManager.play("sfx_npc_murmur", AudioManager.BUS_WORLD_SFX)
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d072_no_zone_specific_murmur_variants() -> void:
|
||||
## D-072: SINGLE universal murmur asset — no zone-specific variants.
|
||||
## "One murmur asset + zone-dependent conspicuousness creates the signal/noise
|
||||
## dynamic naturally." Zone-specific variants MUST NOT exist in the registry.
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_bar")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_corridor")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_hub")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_workplace")).is_false()
|
||||
|
||||
|
||||
func test_d072_murmur_does_not_use_ambient_bus() -> void:
|
||||
## D-072: Bar ambient murmur is baked into amb_bar_layer (continuous background).
|
||||
## The NPC proximity murmur is a SEPARATE event-driven asset on WorldSFX, not Ambient.
|
||||
## Verify bus constant distinction.
|
||||
assert_that(AudioManager.BUS_AMBIENT).is_not_equal(AudioManager.BUS_WORLD_SFX)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 7: AudioManager No-Op Fallback Sanity — D-068 / D-038
|
||||
# ==============================================================================
|
||||
|
||||
func test_d068_registry_size_is_non_negative() -> void:
|
||||
## D-068: Registry is empty when res://audio/ is absent, non-negative always.
|
||||
assert_that(AudioManager.get_registry_size() >= 0).is_true()
|
||||
|
||||
|
||||
func test_d068_play_loop_returns_null_for_missing_asset() -> void:
|
||||
## D-068 / D-038: play_loop() with unregistered asset key returns null (no crash).
|
||||
var result: Variant = AudioManager.play_loop("nonexistent_ambient_xyzabc")
|
||||
assert_that(result == null).is_true()
|
||||
|
||||
|
||||
func test_d068_stop_loop_noop_for_unknown_key() -> void:
|
||||
## D-068: stop_loop() on a key never started — no crash, no error.
|
||||
AudioManager.stop_loop("nonexistent_key_xyzabc")
|
||||
|
||||
|
||||
func test_d068_stop_all_loops_when_none_playing() -> void:
|
||||
## D-068: stop_all_loops() with no active ambient players — no crash.
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
func test_d068_has_asset_returns_false_for_unknown_key() -> void:
|
||||
## D-068 / D-038: has_asset() guards all play methods. Verify false for unknown key.
|
||||
assert_that(AudioManager.has_asset("totally_unknown_asset_key_abc123")).is_false()
|
||||
|
||||
|
||||
func test_d068_play_noop_does_not_change_dip_state() -> void:
|
||||
## D-068: play() on a missing asset must not modify the dip state machine.
|
||||
## Verifies no-op fallback has zero side effects.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.play("nonexistent_asset_xyzabc")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
@@ -139,6 +139,12 @@ func show_dialogue(npc_name: String, speech: String, options: Array = []) -> voi
|
||||
_is_showing = true
|
||||
GameState.dialogue_active = true # D-064: block movement while dialogue visible/fading
|
||||
|
||||
# D-069: Dialogue dip — reduce ambient noise to foreground conversation.
|
||||
# Confrontation options REPLACE (not nest) this dip via apply_dip("confrontation")
|
||||
# in _start_confrontation_beat(). hide_dialogue()'s clear_dip() restores to base
|
||||
# volumes regardless of which profile was last active — intentional replacement semantics.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
|
||||
# D-061: auto-pause in single-player when dialogue opens
|
||||
SimBridge.send_input({"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
||||
|
||||
@@ -156,6 +162,10 @@ func hide_dialogue() -> void:
|
||||
_is_showing = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
# D-069: Clear dialogue/confrontation dip — restore bus volumes to player slider settings.
|
||||
# Safe to call even if confrontation beat already cleared the dip (no-op when empty).
|
||||
AudioManager.clear_dip()
|
||||
|
||||
# D-061: unpause when dialogue closes
|
||||
SimBridge.send_input({"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()})
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
extends Control
|
||||
|
||||
## #528: Audio settings dialog — 5-bus volume sliders.
|
||||
## Opens on OPEN_MENU (ESC) from main.gd. Closes on OPEN_MENU again or CLOSE button.
|
||||
## Volumes persist via AudioManager._save_prefs() on each slider change.
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90)
|
||||
const BORDER_COLOR := Color("#4a9ebb")
|
||||
const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1)
|
||||
const TITLE_COLOR := Color("#4a9ebb")
|
||||
const FONT_SIZE := 14
|
||||
|
||||
const BOX_WIDTH := 460
|
||||
const BOX_HEIGHT := 340
|
||||
const PADDING := 20
|
||||
const ROW_HEIGHT := 36
|
||||
|
||||
# Bus display labels → bus name strings (must match AudioManager BUS_* constants)
|
||||
const BUS_ROWS: Array = [
|
||||
["Music", "Music"],
|
||||
["Ambient", "Ambient"],
|
||||
["World SFX", "WorldSFX"],
|
||||
["Player Actions", "PlayerActions"],
|
||||
["UI Sounds", "UISounds"],
|
||||
]
|
||||
|
||||
var _active: bool = false
|
||||
var _container: VBoxContainer = null
|
||||
|
||||
signal closed
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
|
||||
func open() -> void:
|
||||
if _active:
|
||||
return
|
||||
_active = true
|
||||
visible = true
|
||||
_build_ui()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func close() -> void:
|
||||
if not _active:
|
||||
return
|
||||
_active = false
|
||||
visible = false
|
||||
_destroy_ui()
|
||||
queue_redraw()
|
||||
closed.emit()
|
||||
|
||||
|
||||
func is_open() -> bool:
|
||||
return _active
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var vp_size := get_viewport_rect().size
|
||||
var box_pos := Vector2(
|
||||
(vp_size.x - BOX_WIDTH) / 2.0,
|
||||
(vp_size.y - BOX_HEIGHT) / 2.0
|
||||
)
|
||||
|
||||
_container = VBoxContainer.new()
|
||||
_container.position = box_pos + Vector2(PADDING, PADDING + 28)
|
||||
_container.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0)
|
||||
_container.add_theme_constant_override("separation", 4)
|
||||
add_child(_container)
|
||||
|
||||
for row_data in BUS_ROWS:
|
||||
var label_text: String = row_data[0]
|
||||
var bus_name: String = row_data[1]
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT)
|
||||
_container.add_child(hbox)
|
||||
|
||||
var label := Label.new()
|
||||
label.text = label_text
|
||||
label.custom_minimum_size = Vector2(150, 0)
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
hbox.add_child(label)
|
||||
|
||||
var slider := HSlider.new()
|
||||
slider.min_value = -40.0
|
||||
slider.max_value = 0.0
|
||||
slider.step = 0.5
|
||||
slider.value = AudioManager.get_volume(bus_name)
|
||||
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hbox.add_child(slider)
|
||||
|
||||
var db_label := Label.new()
|
||||
db_label.text = _format_db(slider.value)
|
||||
db_label.custom_minimum_size = Vector2(70, 0)
|
||||
db_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
db_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
db_label.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
db_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
hbox.add_child(db_label)
|
||||
|
||||
slider.value_changed.connect(func(value: float) -> void:
|
||||
AudioManager.set_volume(bus_name, value)
|
||||
db_label.text = _format_db(value)
|
||||
)
|
||||
|
||||
# Spacer
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 8)
|
||||
_container.add_child(spacer)
|
||||
|
||||
# Close button
|
||||
var close_btn := Button.new()
|
||||
close_btn.text = "CLOSE"
|
||||
close_btn.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
close_btn.pressed.connect(close)
|
||||
_container.add_child(close_btn)
|
||||
|
||||
|
||||
func _destroy_ui() -> void:
|
||||
if _container:
|
||||
_container.queue_free()
|
||||
_container = null
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _active:
|
||||
return
|
||||
var viewport_size := get_viewport_rect().size
|
||||
|
||||
# Dim overlay
|
||||
draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR)
|
||||
|
||||
# Dialog box
|
||||
var box_pos := Vector2(
|
||||
(viewport_size.x - BOX_WIDTH) / 2.0,
|
||||
(viewport_size.y - BOX_HEIGHT) / 2.0
|
||||
)
|
||||
var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT))
|
||||
draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95))
|
||||
draw_rect(box_rect, BORDER_COLOR, false, 1.0)
|
||||
|
||||
# Title
|
||||
var font := ThemeDB.fallback_font
|
||||
draw_string(font,
|
||||
box_pos + Vector2(PADDING, PADDING + 18),
|
||||
"AUDIO SETTINGS",
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR)
|
||||
|
||||
|
||||
static func _format_db(db: float) -> String:
|
||||
if db <= -40.0:
|
||||
return "mute"
|
||||
return "%d dB" % int(db)
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/settings_dialog.gd" id="1_settings"]
|
||||
|
||||
; #528: Audio settings dialog — 5-bus volume sliders, OPEN_MENU (ESC) to toggle
|
||||
[node name="SettingsDialog" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_settings")
|
||||
+1
-1
@@ -11,7 +11,7 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
|
||||
| File | Domain | Decisions |
|
||||
|------|--------|-----------|
|
||||
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073 |
|
||||
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072 |
|
||||
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
|
||||
|
||||
+40
-1
@@ -356,6 +356,45 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
- **Amends:** [D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits) (adds pixel value for max-width)
|
||||
- **Raised by:** Stig (OQ-29), revised per Tyre architecture review
|
||||
|
||||
### D-077: Zone temperature memory — server-tracked zone_id (OQ-09 resolution)
|
||||
- **Date:** 2026-02-19
|
||||
- **Decision:** Zone temperature memory is **server-tracked** via `zone_id: Option<u16>` on `VisibleTile` in `ObserverSnapshot`. The server assigns a zone ID to each tile based on the `ZoneMap` resource (spatial zone assignment). The client maps `zone_id` to temperature tint from a local lookup table for deep fog rendering ([D-059](#d-059-fog--shader-based-five-layers-knowledge-graph-driven) layer 3: ~10% zone temperature tint — bar=warm dark, hub=cool dark, corridor=neutral dark).
|
||||
- **Resolves:** OQ-09
|
||||
- **Rationale:** D-073 already mandates `zone_id` per tile in `ObserverSnapshot` for audio zone crossfade. With `zone_id` already on the wire for crossfade, zone temperature memory is essentially free — no additional protocol field needed, no additional server computation beyond the zone lookup. Client-only tracking was rejected because: (1) the server is the authoritative source of zone geometry, (2) client heuristics (remembering last-visited zone) would diverge from server truth at zone boundaries, and (3) the data is already crossing the wire for D-073.
|
||||
- **Implementation:**
|
||||
- `VisibleTile.zone_id: Option<u16>` — `None` for tiles outside any defined zone (corridors, transition spaces). Uses `#[serde(default, skip_serializing_if)]` for backwards compatibility with v10 clients.
|
||||
- `ZoneMap` resource (`simulation/zone.rs`): `BTreeMap<(i32, i32, i32), u16>` mapping tile coordinates to zone IDs. BTreeMap per [D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline) principle 4 (deterministic iteration). Populated by map builders at setup time.
|
||||
- Zone enrichment runs in `compute_observer_snapshot` — the observer pipeline's final assembly stage. `PerceptionQuery` trait remains zone-unaware (zone assignment is map data, not perception geometry).
|
||||
- Protocol version bumped to 11.
|
||||
- **Client contract:** Client maintains a `zone_id → { name, temperature_tint, ambient_layer }` lookup table. Deep fog shader (layer 3) reads `zone_id` from the last-seen `VisibleTile` data to apply the ~10% temperature tint. AudioManager reads `zone_id` to trigger crossfade between ambient layers ([D-073](architecture.md#d-073-zone-crossfade-approach--hard-boundary-soft-audio-transition)).
|
||||
- **Cross-reference:** Fog layers ([D-059](#d-059-fog--shader-based-five-layers-knowledge-graph-driven)), zone crossfade ([D-073](architecture.md#d-073-zone-crossfade-approach--hard-boundary-soft-audio-transition)), ObserverSnapshot ([D-020](architecture.md#d-020-engine-and-architecture-selection--godot-client--rust-simulation-via-subprocessipc)), deterministic simulation ([D-010](architecture.md#d-010-multiplayer-ready-architectural-baseline))
|
||||
- **Raised by:** Tyre (architecture review, #523)
|
||||
- **Dissent:** None
|
||||
|
||||
### D-078: Overheard NPC conversation — passive dialogue panel with occlusion filter
|
||||
- **Date:** 2026-02-19
|
||||
- **Decision:** NPC-to-NPC conversations overheard by the player are displayed in the **dialogue panel in passive mode** (read-only, no response options). An **occlusion filter** degrades displayed text based on distance, ambient noise, and player effort (ListeningFocus stance). Monologue remains a separate channel — it reacts to overheard content via the `witness_interaction` trigger but does not carry conversation text itself.
|
||||
- **Passive panel behavior:**
|
||||
- Same D-061 dialogue box, but read-only — no response options rendered.
|
||||
- Shows speaker name and intended target (e.g. "Kael → Mira").
|
||||
- Visually distinguished from interactive dialogue (dimmed border or header treatment — exact style TBD by Araminta).
|
||||
- Walking out of earshot dismisses the panel naturally.
|
||||
- **Occlusion filter — server-authoritative:**
|
||||
- Occlusion is computed **on the server**. The server rolls per-word visibility based on distance, ambient noise, and ListeningFocus stance, then sends pre-occluded text to the client with `...` replacing dropped words. The client renders exactly what it receives — no client-side probability logic.
|
||||
- Per-word visibility is a **probability**, not a deterministic cutoff. Each word is independently rolled — same distance, same conversation can yield different fragments across playthroughs.
|
||||
- **Close / clear:** High probability per word — most or all text arrives intact, speaker names shown.
|
||||
- **Moderate distance / some noise:** Medium probability — words arrive as `...`, speaker names visible. The player may or may not catch the crucial word.
|
||||
- **Far / noisy zone:** Low probability — speaker indicators only, few or no readable words. Audio murmur still plays per D-072.
|
||||
- **ListeningFocus stance:** Shifts probability curve upward — improves word visibility at current distance.
|
||||
- **Multiplayer-readiness:** Each observer gets their own occluded version computed from their specific distance/noise/stance. Seedable RNG per tick ensures deterministic replay (D-010).
|
||||
- **Design intent:** Stochastic word-drop means the player can never be certain they got the full picture from eavesdropping alone. This pushes them toward direct conversation to confirm — where they must reveal something about what they know. The information asymmetry loop closes on itself.
|
||||
- **Dual-channel principle:** Dialogue panel = what you hear. Monologue = what you think about it. The `witness_interaction` monologue trigger fires after overheard content is displayed, producing the character's internal reaction ("She's lying to him", "So they do know about the cargo"). These are separate UI elements with separate content pipelines.
|
||||
- **Content pipeline:** Copy team authors NPC-to-NPC dialogue lines once at full fidelity. The server applies occlusion degradation before sending to the client. No need to author multiple fidelity versions.
|
||||
- **Rationale:** Keeps monologue as a clean internal response system — thoughts only, never quoted speech. The dialogue panel already exists for player-NPC interaction; reusing it in passive mode for overheard conversation avoids a new UI element. The occlusion filter makes proximity and attention mechanically meaningful without gating content behind binary thresholds.
|
||||
- **Cross-reference:** Dialogue box ([D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits)), eavesdropping ([D-071](#d-071-no-ambient-dip-for-eavesdropping--listeningfocus-boost)), conversation murmur ([D-072](#d-072-universal-event-driven-conversation-murmur)), monologue system ([D-016](#d-016-internal-monologue-as-core-perceptionatmosphere-system)), knowledge confidence ([D-041](architecture.md#d-041-knowledge-graph-data-model))
|
||||
- **Raised by:** Lead (passive panel directive, occlusion filter, monologue separation)
|
||||
- **Dissent:** None
|
||||
|
||||
---
|
||||
|
||||
*30 decisions. Last updated: 2026-02-19 (D-076: OQ-29 resolved — dialogue max-width 640px)*
|
||||
*32 decisions. Last updated: 2026-02-19 (D-078: overheard NPC conversation — passive dialogue panel with occlusion filter)*
|
||||
|
||||
@@ -15,6 +15,7 @@ Mixed methods depending on duration and character:
|
||||
|----|----------|--------|-----|--------|----------|-------------|--------|
|
||||
| SFX-001 | `sfx_footstep_metal_walk.ogg` | done | Player Actions | SAO hybrid | 0.3s | Single footstep on metal grating. Walk pace. SAO 5s/100 steps/CFG 8, best transient trimmed at 2.42s, LUFS -16 normalized. | S10 #327 |
|
||||
| SFX-002 | `sfx_footstep_metal_run.ogg` | done | Player Actions | SAO hybrid | 0.3s | Faster footstep, sprint pace. Louder — player is broadcasting position (D-053 acoustic footprint). SAO 5s/100 steps/CFG 8, best transient trimmed at 1.03s, LUFS -16 normalized. | S10 #327 |
|
||||
| SFX-003 | `sfx_npc_murmur.ogg` | done | World SFX | SAO | 5s | NPC proximity conversation murmur. Human voices in adjacent room, direction and cadence distinguishable, words not. Organic family. SAO 5s/100 steps/CFG 8, LUFS -16 normalized. Selected from 3 candidates (richest texture). | S13 #532 |
|
||||
|
||||
## Detailed Entries
|
||||
|
||||
@@ -25,6 +26,17 @@ Mixed methods depending on duration and character:
|
||||
- **Integration:** Triggered by player movement tick. Positional (AudioStreamPlayer2D at player position) — maps to camera-relative stereo per D-018.
|
||||
- **Cross-reference:** D-038 (#5), D-053 (movement stances as acoustic decisions)
|
||||
|
||||
### SFX-003: sfx_npc_murmur
|
||||
- **Sonic family:** organic (physical world — human warmth, natural decay)
|
||||
- **Bus rationale:** World SFX — this is information about other people in the world, not player feedback. Positional (AudioStreamPlayer2D at NPC cluster position).
|
||||
- **Design intent (D-072):** Universal asset — single murmur plays at all NPC proximity events. Zone ambient determines conspicuousness naturally: bar's dense ambient buries it, corridor's sparse ambient exposes it. No zone-specific variants needed.
|
||||
- **Aesthetic (D-074):** Organic family. Warm, lived-in, no lattice processing. Mid-freq 200-800Hz texture per station palette "functional warmth" (D-074 conversation murmur band).
|
||||
- **Propagation (D-018):** Close range: intelligible as "voices, nearby." Medium range: muffled. Long range: lost in ambient floor.
|
||||
- **Eavesdrop interaction (D-071):** Player ListeningFocus boost increases effective reception range. Same asset, confidence varies with proximity and skill.
|
||||
- **Generation:** SAO 5s / 100 steps / CFG 8. Prompt: "Warm organic audio, natural sound recording, slight room ambience, lived-in industrial space, realistic acoustic properties, authentic environmental sound. Single isolated sound effect, clean recording, 5 seconds duration, clear attack and natural decay, no background noise, no reverb tail beyond natural. Human voices murmuring through a thin wall or partition, two or three people in low conversation in the adjacent room, words muffled and indistinct, direction and gentle cadence audible, warm mid-frequency vocal texture 200-800Hz, soft intimate conversation murmur, no music, no intelligible words." 3 candidates generated (5s/CFG8, 6s/CFG7, 4s/CFG9). Candidate 1 selected (richest OGG texture, clearest spatial framing). Post-processed: trim, LUFS -16, OGG.
|
||||
- **Integration:** Event-driven — fires when player enters NPC cluster proximity range. Not looped continuously; retriggered on proximity events. Loop-capable (5s has natural loop structure) if client opts to sustain during eavesdrop.
|
||||
- **Cross-reference:** D-072 (universal murmur decision), D-071 (ListeningFocus eavesdrop), D-074 (organic aesthetic), D-018 (sound propagation model)
|
||||
|
||||
## Future Expansion
|
||||
|
||||
SFX will grow significantly with:
|
||||
@@ -32,7 +44,6 @@ SFX will grow significantly with:
|
||||
- Door open/close, machinery activation → World SFX bus
|
||||
- Combat impacts, weapon sounds → Player Actions bus
|
||||
- Environmental events per planet/biome
|
||||
- NPC conversation murmur (event-driven, point-source) → World SFX bus
|
||||
|
||||
When this file exceeds ~50 entries, split by domain:
|
||||
- `sfx-movement.md` (footsteps, stance variants)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Sprint 14: Live — Client Tasks
|
||||
|
||||
**Goal:** Bring NPCs to life — mood, trust, routine execution, and NPC-to-NPC conversation drive the first emergent social observables; the simulation produces a world that breathes independently of the player.
|
||||
|
||||
**Branch:** `client`
|
||||
**Agents:** Stig (UI/rendering dev), Tyre (architect), Hoshe (QA)
|
||||
|
||||
## Carry-over from Sprint 13
|
||||
|
||||
None. All Sprint 13 client tickets done.
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #122 | Monologue display — client | — |
|
||||
| #535 | Passive dialogue panel — overheard NPC conversation display | #247 (server) |
|
||||
| #511 | F3 debug overlay (deferred — WRONG button covers same data) | #495 done |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-020 (ObserverSnapshot — `current_monologue` field), D-042 (UI microcopy format — YAML autoload for HUD labels)
|
||||
- `decisions/perception.md` — D-016 (internal monologue — text display, atmosphere function), D-018 (three-range sound model — NPC-to-NPC conversations are Voice events), D-055 (sprint suppresses monologue at 40% rate — client renders what server sends; no client-side suppression needed)
|
||||
- `decisions/content.md` — D-032 (separate monologue pools per character — client only needs to render; no character branching in display layer), D-035 (tag taxonomy — monologue has `character`, `trigger`, `prerequisite` tags; client ignores, server filters)
|
||||
- D-078 (overheard NPC conversation — passive dialogue panel, server-authoritative occlusion, pre-occluded text rendering, speaker attribution)
|
||||
|
||||
## Notes
|
||||
|
||||
- **#122 — Monologue display — client:** The server-side monologue trigger system is complete — `MonologueEvent` arrives in `ObserverSnapshot.current_monologue` (field added Sprint 5, `server/src/bridge/types.rs`). The visual spec for monologue display was authored in Sprint 13 (#315, `docs/design/monologue-display-spec.md`). Sprint 14 work: implement the client-side display using that spec. Key requirements from the spec:
|
||||
- **Position:** Bottom-left of viewport, above the interaction verb list, below the minimap. Not in the dialogue box area (max 20% height, D-061).
|
||||
- **Typography:** Italic, smaller than dialogue (spec has exact px/pt — read `docs/design/monologue-display-spec.md` §Typography).
|
||||
- **Stacking:** Queue — if a new monologue arrives while one is displaying, queue it (max depth from spec). Do not overwrite mid-display. Sprint 13 note: "monologue not lost on overwrite" is a P0 test (#477, done).
|
||||
- **Fade:** Auto-fade after display duration. Duration is embedded in `MonologueEvent.display_duration` (float, seconds). Standard Godot `Tween`.
|
||||
- **Character differentiation:** Smuggler and detective lines use different text colours — hex values in spec. Client reads the active character from `game_state.gd` (autoload at `client/scripts/autoloads/game_state.gd`) or derives it from the snapshot. The server already partitions monologue pools per character (D-032) so the client receives only the correct character's lines — no client-side branching needed.
|
||||
- Existing `world_renderer.gd` (`client/scripts/rendering/world_renderer.gd`) is the root scene; monologue display is a new `CanvasLayer` child node or a `Control` node in the HUD layer (z-layer 6 per D-049 rendering stack). Do not put it in the dialogue box scene.
|
||||
- **Integration with audio:** Monologue display does NOT trigger audio (monologue chime fires at cognitive delay onset from the server's recognition pipeline, not from text display). No client-side audio calls needed here.
|
||||
- Delivery: `client/scripts/rendering/monologue_display.gd` (or similar), integrated into the HUD scene, reading `ObserverSnapshot.current_monologue`. Tests via Hoshe's gdUnit4 harness: queue management, fade-out timing, character colour, no-overwrite on active display.
|
||||
|
||||
- **#535 — Passive dialogue panel — overheard NPC conversation display:** Consumes `ConversationEvent` from `ObserverSnapshot` (added by server #247). Render overheard NPC-to-NPC conversations in the existing dialogue box in passive (read-only) mode. Per D-078 amendment — occlusion is server-authoritative; this ticket is a pure renderer of pre-occluded text. Key requirements:
|
||||
- **Speaker attribution:** Display header as "Speaker → Target" (e.g. "Kael → Mira") using `speaker_name` and `target_name` from the event. Use the same attribution label slot as player conversation NPC-name display — no new layout region needed.
|
||||
- **Pre-occluded text renderer:** Render `occluded_line` as-is. The server has already performed per-word Bernoulli drops and replaced dropped words with `...`. The client performs no stochastic logic — no `clarity_score`, no `randf()`, no per-word processing. Display the received string directly in the passive panel.
|
||||
- **Read-only mode:** The dialogue box must suppress response options and player input when displaying a passive conversation. No response list, no input capture. Player can still move freely.
|
||||
- **Dismissal:** On `conversation_end` event (or if player moves far enough that no `ConversationEvent` arrives for the NPC pair), dismiss via the standard 300ms fade (same `Tween` as walk-away fade, #437 done).
|
||||
- **Monologue isolation:** Monologue display (#122, z-layer 6) is unaffected. Do not route conversation text through the monologue display. The server triggers `witness_interaction` which may produce a monologue — that arrives separately via `current_monologue` and is rendered by #122 normally.
|
||||
- **Blocked by #247** — `ConversationEvent` struct must exist on `ObserverSnapshot` before this can be wired. Start with a stub event type and placeholder renderer if #247 is not yet landed.
|
||||
- Delivery: passive mode in `client/scripts/ui/dialogue_box.gd` (or a new `passive_dialogue.gd` sibling), verbatim `occluded_line` render, speaker attribution header, `conversation_end` dismissal. Tests via Hoshe's gdUnit4 harness: attribution renders correctly, `occluded_line` with `...` tokens renders verbatim, panel does not show response options in passive mode, dismisses on `conversation_end`.
|
||||
|
||||
- **#511 — F3 debug overlay (deferred):** This ticket is low priority. The WRONG button (F12, #507 done) already captures snapshot state, text render, and description on demand without per-frame cost. The F3 overlay would duplicate that information as a persistent overlay. Stig recommended deferral in Sprint 12. Only implement this sprint if server tickets finish early and QA explicitly needs real-time overlay data that WRONG button cannot provide. Default: skip.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#122 (monologue display) — standalone, no server dependency this sprint
|
||||
#535 (passive dialogue panel) — blocked by #247 (server, NPC-to-NPC conversation system)
|
||||
#511 (F3 overlay) — standalone, low priority, implement only if capacity allows
|
||||
```
|
||||
|
||||
#122 has no blockers — `current_monologue` in the snapshot has been live since Sprint 5. The spec (#315) is done. This is a pure client rendering task.
|
||||
|
||||
#535 is blocked on #247 landing the `ConversationEvent` struct with `occluded_line: String` on `ObserverSnapshot`. Stig can stub the event type locally and build the renderer against it in parallel; wire-up follows when #247 merges. No stochastic logic needed on the client — render `occluded_line` verbatim.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(client): description" --description "body" --base main --head client
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# Sprint 14: Live — Copy Tasks
|
||||
|
||||
**Goal:** Bring NPCs to life — mood, trust, routine execution, and NPC-to-NPC conversation drive the first emergent social observables; the simulation produces a world that breathes independently of the player.
|
||||
|
||||
**Branch:** `copy`
|
||||
**Agents:** Mellanie (author), Paula (narrative designer), Gestalt (systems designer)
|
||||
|
||||
## Carry-over from Sprint 13
|
||||
|
||||
None. All Sprint 13 copy tickets done (#368 knowledge vocabulary).
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #253 | Monologue content architecture | — |
|
||||
| #120 | Monologue line pool system | — |
|
||||
| #121 | Character voice variation | — |
|
||||
| #168 | Tagged line pool structure | #240 done |
|
||||
| #328 | Access tier shift design document | #261 done |
|
||||
| #536 | NPC-to-NPC overheard dialogue line pool | — |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/content.md` — D-028 (dialogue architecture — four relational layers; #168 implements Layer 1-2 tag structure), D-032 (separate monologue pools per character — hard partition, no shared lines), D-034 (THE FRIEND — Kael Davan / Sera Venn, all monologue content must be coherent with their arcs), D-035 (converged tag taxonomy — 6 structural + 3 selection + 3 monologue-specific tags), D-036 (Sova Transit District setting — Krenn naming conventions, "functional warmth" tone), D-037 (contraband — unlicensed lattice components, moral ambiguity), D-062 (invisible locked dialogue options — new trust tier content appears silently), D-063 (confrontation mechanics — internal voice, italic first-person options), D-064 (walk-away consequences — KG records incompleteness)
|
||||
- `decisions/perception.md` — D-016 (internal monologue — four functions: perception bridge, atmosphere, diegetic tutorial, unreliable narrator), D-018 (three-range sound model — NPC-to-NPC conversations are Voice events the player overhears)
|
||||
- D-078 (overheard NPC conversation — lines must survive partial occlusion; front-load key information, short declarative sentences, avoid pronoun-first openers)
|
||||
|
||||
## Notes
|
||||
|
||||
- **#253 — Monologue content architecture:** Define the authoring contract for all future monologue content. This is a **design document**, not a content file. Output: `docs/design/monologue-content-architecture.md`. Required sections:
|
||||
- Identity-driven interpretation frame: what each character "cares about, fears, is responsible for" — the interpretive lens that makes the same observation produce different monologue per character. Smuggler: exposure, protection of ring, Kael's loyalty. Detective: institutional duty, personal cost, Sera Venn's reliability.
|
||||
- Category definitions: perception (translating non-visual senses), atmosphere (environment, mood), tutorial (diegetic hints), observation (NPC-specific). Add a fifth if needed.
|
||||
- Trigger → category mapping: which triggers (from D-035: `enter_location`, `observe_npc`, `hear_sound`, `observe_anomaly`, `post_conversation`, `discover_evidence`, `witness_interaction`, `time_idle`, `return_visit`) map to which categories. Not 1:1 — one trigger can source multiple categories.
|
||||
- Volume target: how many lines per trigger type per character for v0.1. Per D-032, all volumes are per-character.
|
||||
- `prerequisite` map design: what game state conditions gate a line. Examples: `kael_interaction_count >= 1`, `knows_kael_has_secret = true`, `location = maintenance_corridor`. These must be expressible in the KG (D-041 FactIds).
|
||||
- Mellanie authors, Paula reviews for narrative coherence with THE FRIEND arc (D-034), Gestalt validates prerequisite map is implementable from KG state.
|
||||
|
||||
- **#120 — Monologue line pool system:** Produce the first monologue line pool YAML files. Output: `content/` directory (follow existing content structure — see `server/src/content/mod.rs` and `server/src/content/loader.rs` for load path conventions). File structure per D-032: `monologue-smuggler.yaml`, `monologue-detective.yaml`, with subdirectory per location (`terminal/`, `bar/`, `corridor/`). Required tags per line per D-035 (monologue-specific additions): `character`, `trigger`, `prerequisite` (null if unconditional). All 6 structural tags required: `id`, `text`, `role`, `access`, `trust`, `situation`. For monologue, `role` = `player_character`, `access` = `[public]`, `trust` = `surface` (monologue is the player character's internal voice — no access/trust gating applies; tags must still be present for schema compliance).
|
||||
- Minimum deliverable for Sprint 14: `enter_location` (2-3 lines per location per character) and `time_idle` (2-3 lines per location per character). Eight locations minimum (hub, bar, corridor × 2 characters = 6; add 2 more for the maintenance corridors or gauntlet-equivalent spaces).
|
||||
- Line quality bar: these are the first lines the game speaks. Every line must pass Mellanie's voice test. Per D-034 THE FRIEND guidance: "no generation expansion" for THE FRIEND content specifically — apply same standard to monologue that will surface during THE FRIEND arc (Kael/Sera scenes).
|
||||
|
||||
- **#121 — Character voice variation:** Write the trait modifier system for monologue voice. This is a **companion authoring guide** to #253, not a separate system. Output: `docs/design/monologue-voice-guide.md`. Required:
|
||||
- Smuggler vs detective base voice registers (tone, vocabulary, sentence structure).
|
||||
- How `PersonalityTraits` (D-024 — `Cautious`, `Bold`, `Honest`, `Deceptive`, `Compassionate`, `Ruthless`, `Curious`, `Incurious`, `Social`, `Reclusive`) modify monologue delivery. Not per-trait scripts — a transformation guide: "a Cautious character says X instead of Y" with 2-3 example rewrites.
|
||||
- Background-dependent phrasing: Guardian background vs Senator background vs Worker background produce different idioms, references, assumptions. At minimum: two examples per background per character.
|
||||
- Mood influence (feeds into #323 server-side): how the 8+1 moods (D-035 amendment: `neutral`, `anxious`, `frustrated`, `content`, `suspicious`, `warm`, `hostile`, `focused`) colour monologue delivery. Not separate line pools — the same line can be delivered more tersely when the character is Anxious, more expansively when Content.
|
||||
- Paula writes the narrative framing, Mellanie writes the example lines, Gestalt validates mood → delivery mappings are mechanically coherent.
|
||||
|
||||
- **#168 — Tagged line pool structure:** Implement the converged D-035 YAML schema as a formal JSON Schema or YAML schema document and update the line previewer CLI. Output: `content/_schema/dialogue-line.schema.json` (or `.yaml`) with all 6 structural + 3 selection + 2 authoring-only tags. Monologue additions (`character`, `trigger`, `prerequisite`) as a separate schema extension or `$ref`. The schema is what the content cross-reference validation (CI ticket #464, done) runs against. Update the line previewer CLI (Sprint 5 `#326`, already done) to validate against the new schema on load. Gestalt leads schema design. Mellanie validates against authoring workflow. The 14 v0.1 situations and 9 v0.1 topics from D-035 must be enumerated in the schema as allowed values.
|
||||
- Note: `server/src/content/line_pool.rs` already implements the Rust-side `IndexedDialogueLine` type — schema must stay in sync. Cross-reference `server/src/content/line_pool.rs` to confirm enum variants match D-035 enumeration.
|
||||
|
||||
- **#328 — Access tier shift design document:** A design document authored by Paula and Mellanie (no code). Output: `docs/design/access-tier-shifts.md`. Required per ticket:
|
||||
- Per social site, 2-3 most likely tier transitions per character with specific triggers.
|
||||
- Smuggler at The Terminal (hub): `insider → hostile` when cover blown; `insider → peer` after routine trust-building.
|
||||
- Smuggler at The Last Shift (bar): `public → insider` after Kael introduction; `insider → hostile` if contraband conversation overheard.
|
||||
- Detective at The Terminal: `authority → peer` after collaboration event; `authority → hostile` if confrontation fails.
|
||||
- Detective at The Last Shift: `public → insider` via Sera Venn; `authority → peer` after buying rounds.
|
||||
- For each transition: what observable event triggers it, which KG facts are required (`FactId` references from #368 knowledge vocabulary), and whether it is reversible.
|
||||
- This document feeds #169 (Layer 1 access tier filtering, deferred to S15) as its design input.
|
||||
|
||||
- **#536 — NPC-to-NPC overheard dialogue line pool:** Write the line pool for NPC-to-NPC conversations that the player can overhear, per D-078. Lines must work at both full clarity (heard normally) and under heavy occlusion (many words dropped). The stochastic word-drop renderer operates per-word — lines must be authored so that partial information is still meaningful and interesting, not just broken.
|
||||
- **Occlusion-resilient authoring rules:** (1) Front-load key information — the most important word should be in the first third of the sentence. (2) Short declarative sentences — one idea per line turn. (3) No pronoun-first openers (`"She told me..."` → `"Kael told me..."`) — the first word is statistically the most likely to drop; pronouns without antecedent are unresolvable. (4) Each exchange turn must be self-contained — a player who hears only one side of the exchange should still get a complete thought.
|
||||
- **Volume and tagging:** Minimum 40 exchange pairs (speaker A line + speaker B response = 1 pair). Cover three registers: social (idle chat, personal news), work (shift logistics, job gripes), gossip (third-party knowledge payload — something the player could use). Tag each pair with: `relationship_type` (colleague, friend, hostile, romantic), `topic`, and `knowledge_payload` (a brief description of what the player can infer from hearing this exchange clearly — or partially).
|
||||
- **Format:** Use the D-035 YAML schema (see #168 for schema file). These are NPC-sourced lines, not player-character lines — `role` = `npc`, `access` and `trust` tags apply normally. Monologue-specific tags (`character`, `trigger`, `prerequisite`) are not used for this pool; replace with `relationship_type` and `knowledge_payload` as custom extensions, or confirm schema handling with Gestalt before writing.
|
||||
- **Placement:** Output to `content/npc-conversations/overheard.yaml` (new directory, establish convention).
|
||||
- Mellanie writes lines, Paula validates narrative coherence (Kael/Sera-adjacent exchanges must honour D-034 THE FRIEND arc), Gestalt validates knowledge_payload tags are achievable from KG state.
|
||||
- Delivery: `content/npc-conversations/overheard.yaml`, minimum 40 exchange pairs, all tagged, all passing the occlusion-resilience checklist. No dependency on server #247 — content is authored ahead of the system that will select and emit it.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#253 (monologue architecture) → #120 (line pool files) → #121 (voice guide)
|
||||
#168 (tagged line pool schema) → parallel with #253 track
|
||||
#328 (access tier shifts) → parallel, standalone design doc
|
||||
#536 (NPC-to-NPC overheard line pool) → parallel, standalone, feeds server #247
|
||||
```
|
||||
|
||||
#253 must be started first — it defines the authoring contract that #120 writes to and #121 extends. #120 and #121 can overlap once #253 is drafted (Paula/Mellanie can start voice guide while Mellanie writes lines). #168 and #328 are fully independent and can proceed in parallel with the monologue track. #536 is also fully independent — no blockers, can proceed in parallel with all other copy work.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "docs(content): description" --description "body" --base main --head copy
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# Sprint 14: Live — Joint / Integration Notes
|
||||
|
||||
**Goal:** Bring NPCs to life — mood, trust, routine execution, and NPC-to-NPC conversation drive the first emergent social observables; the simulation produces a world that breathes independently of the player.
|
||||
|
||||
**Sprint 14 ID:** 14
|
||||
**Status:** planning (activate with `db/connectors/sprint start`)
|
||||
|
||||
---
|
||||
|
||||
## Pre-Sprint Decisions
|
||||
|
||||
The following decisions are implemented or directly exercised this sprint and must be cross-referenced by the relevant team:
|
||||
|
||||
| Decision | Domain | Implementing ticket(s) |
|
||||
|----------|--------|----------------------|
|
||||
| D-024 (10-axis NPC model + combat component) | content | #323 (mood), #324 (trust), #325 (interaction tracking), #101 (routine) |
|
||||
| D-026 (simulation tiers — ActiveSim scope for behavior) | architecture | #323, #101, #247 |
|
||||
| D-028 (dialogue — four relational layers) | content | #325 (Layer 2 data), #324 (Layer 3 data), #168 (schema) |
|
||||
| D-031 (game clock / day phases — routine transitions) | architecture | #101 |
|
||||
| D-032 (separate monologue pools per character) | content | #120, #121, #122 |
|
||||
| D-034 (THE FRIEND — Kael Davan / Sera Venn) | content | #318 (visual spec), #253 (monologue architecture) |
|
||||
| D-035 (converged tag taxonomy) | content | #168 (schema implementation) |
|
||||
| D-041 (knowledge graph — trust co-gate) | architecture | #324, #325 |
|
||||
| D-075 (dialogue filtering — trust + confidence co-gate) | content | #324 (trust progression feeds `relationship_to_trust()`) |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Team Dependencies
|
||||
|
||||
| Dependency | From | To | Risk |
|
||||
|------------|------|----|------|
|
||||
| #323 (Mood component) | server | copy #253 (monologue architecture must describe mood influence) | Low — copy doc can reference Mood enum; server implements |
|
||||
| #325 (InteractionMemory) | server | copy #328 (access tier shifts doc references interaction count as trigger) | Low — spec can be written with intent; server implements |
|
||||
| #324 (trust progression) | server | copy #121 (voice guide references mood/trust for line selection) | Low — parallel, doc references trust tier conceptually |
|
||||
| #304 (entity color spec) | visual | client (future sprint — entity_renderer.gd implementation) | Low — spec must be complete before client implements |
|
||||
| #316 (text display hierarchy) | visual | client #122 (monologue display references hierarchy for positioning) | Low-Medium — Stig should read #316 spec before completing #122 layout |
|
||||
| #251 (tell visual/behavioral spec) | visual | server (future sprint — tell simulation behavior) | Low — server implements behavior from Araminta's spec next sprint |
|
||||
|
||||
**Coordination protocol:**
|
||||
- Visual team (#304 first): Start entity color spec before #318 (THE FRIEND visual cross-references it). Signal to joint channel when #304 is drafted.
|
||||
- Client team (#122): Read the Sprint 13 monologue display spec (`docs/design/monologue-display-spec.md`) before implementing. Also read #316 once Araminta drafts it — consult if positioning conflicts arise. Default to #315 spec if #316 is not ready.
|
||||
- Server team (#323 first): Mood component is the most downstream dependency for copy and visual. Draft the `Mood` enum in `server/src/npc/mood.rs` early — copy team needs the enum values for #253 authoring contract and #121 voice guide.
|
||||
- Copy team (#253 first within copy track): Monologue architecture doc gates #120 and partially gates #121. Start it day one.
|
||||
|
||||
---
|
||||
|
||||
## Sprint Completion Proof
|
||||
|
||||
When Sprint 14 is done, the following is concretely observable:
|
||||
|
||||
1. **NPCs have moods:** Query the ECS world during a gauntlet run — Active-tier NPCs have a `Mood` component with a non-Neutral value after sufficient simulation ticks. The `Mood` transitions when `ToleranceThreshold.current_stress` exceeds threshold, or when day phase changes.
|
||||
|
||||
2. **Trust accumulates:** Talk to the same NPC three times in sequence. After each interaction, `RelationshipGraph.get_relationship(player_id, npc_id).trust` increments. Walk away mid-conversation (D-064 mechanic) — trust decrements. Trust values are deterministic across replays with the same input sequence.
|
||||
|
||||
3. **Routine execution closes the loop:** NPCs in the Active tier move to their routine destination on day-phase transition. After `Morning → Afternoon`, NPCs with an Afternoon `location` in their `DailyRoutine` have an active `PathRequest` issued and eventually reach the target tile. The `ActivityState` component reflects the current routine activity string.
|
||||
|
||||
4. **NPC-to-NPC conversations generate sound:** Two NPCs in the same zone within 3 tiles emit a `SoundEvent` of kind `Voice` on the `ObserverSnapshot.sound_events` field. The client's `sound_indicator_renderer.gd` renders a voice-coloured (#e8c547) pulse at the fog edge in the direction of the conversation. The `AudioManager` plays the murmur asset on the WorldSFX bus (already wired Sprint 13).
|
||||
|
||||
5. **Monologue displays in client:** Launch the game. Walk into a new room. Within 2 ticks, a monologue line appears in the bottom-left HUD area in italic text. Wait idle for ~100 ticks — a `time_idle` line appears. Lines fade after their display duration. No line is lost — if a new line arrives while one is showing, it queues. Character-specific colour is applied.
|
||||
|
||||
6. **Monologue content pools exist:** `content/` directory contains `monologue-smuggler.yaml` and `monologue-detective.yaml` files with at least 8 lines each covering `enter_location` and `time_idle` triggers for at least two locations. All lines validate against the D-035 schema in `content/_schema/dialogue-line.schema.json`.
|
||||
|
||||
7. **Tagged line pool schema is authoritative:** Running `make pre-pr` (CI cross-reference validation, #464) validates all content YAML files against the schema. No validation errors.
|
||||
|
||||
8. **Visual specs are actionable:** `docs/design/entity-color-system.md`, `docs/design/text-display-hierarchy.md`, `docs/design/sound-indicator-visual.md`, `docs/design/the-friend-visual-treatment.md`, `docs/design/environmental-text-standards.md`, and `docs/design/tell-visual-expression.md` all exist with sufficient detail for Stig to implement from the spec alone — hex values, pixel dimensions, z-layer assignments, animation durations.
|
||||
|
||||
9. **Invariant test suite passes:** `cargo test` in `server/` includes `invariants::*` tests that execute against all existing gauntlet rooms without failure. 36 invariants across 4 categories all assert green.
|
||||
|
||||
10. **Access tier shift design exists:** `docs/design/access-tier-shifts.md` documents 2-3 tier transitions per character per social site with specific KG fact triggers and reversibility notes. Ready as design input for #169 (Layer 1, S15).
|
||||
|
||||
---
|
||||
|
||||
## Test Plan Alignment (D-030)
|
||||
|
||||
Sprint 14 is in the integration phase (D-030 Phase 2+). Test focus:
|
||||
|
||||
- **Server:** New systems (#323 mood, #324 trust, #325 interaction tracking, #101 routine, #103 relationship dynamics, #247 NPC conversations) each require unit tests in their respective modules. Hoshe's test harness pattern: ECS `World` setup, inject components, advance system, assert state change. Determinism required: all random values through `SimRng` (D-010 principle 4). #508 invariant tests run as integration-style tests over gauntlet rooms — these are the first map-agnostic quality gate.
|
||||
- **Client:** Hoshe's gdUnit4 harness. #122 (monologue display) tests: queue management (queue depth at max, oldest-first eviction?), fade timing, character color correctness, no-overwrite behavior. Verify `current_monologue: None` produces no display (no ghost text from previous tick).
|
||||
- **Copy:** Content validation is CI-driven (#464 cross-reference check in `make pre-pr`). Line pool YAML files (#120) must pass schema validation. No manual test required for docs (#253, #121, #328, #168).
|
||||
- **CI:** `make ci` must pass on all branches. `make pre-pr` runs content cross-reference validation including schema check. No new HashMap in simulation crate (clippy ban active).
|
||||
|
||||
---
|
||||
|
||||
## Decision Coverage Gaps (remaining after Sprint 14)
|
||||
|
||||
The following confirmed decisions still have no implementing tickets. Flag to Team Leader if any block upcoming work:
|
||||
|
||||
- D-031 (time system) — exercised by #101 routine execution and gauntlet shift change room. No standalone ticket needed.
|
||||
- D-033 (entity color = relationship to player) — #304 this sprint is the spec. Client implementation is a Sprint 15 ticket (not yet created).
|
||||
- D-036 (Sova Transit District setting) — no world map authoring ticket yet. Relevant to #155 (hand-crafted location authoring, backlog).
|
||||
- D-040 (wiki taxonomy) — documentation structure, not code.
|
||||
- D-043–D-046 (art direction decisions) — referenced by #304, #316, #317, #334 this sprint. No standalone implementing tickets.
|
||||
- D-062 (invisible locked dialogue) — design constraint, implemented by server-side filtering (already in `dialogue.rs`). No new ticket.
|
||||
- D-070 (confrontation as cognitive vulnerability) — implemented Sprint 13 (audio dip). No further ticket.
|
||||
- D-074 (audio aesthetic identity) — implemented Sprint 13 (murmur asset). No further ticket.
|
||||
|
||||
---
|
||||
|
||||
## Notes for Sprint Start
|
||||
|
||||
To activate the sprint once planning is approved:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint start
|
||||
```
|
||||
|
||||
This sets Sprint 14 to `active`. Teams then run `db/connectors/sprint start-work --team <team>` for their full context dump.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Sprint 14: Live — Server Tasks
|
||||
|
||||
**Goal:** Bring NPCs to life — mood, trust, routine execution, and NPC-to-NPC conversation drive the first emergent social observables; the simulation produces a world that breathes independently of the player.
|
||||
|
||||
**Branch:** `server`
|
||||
**Agents:** Dudley (simulation dev), Tyre (architect), Hoshe (QA)
|
||||
|
||||
## Carry-over from Sprint 13
|
||||
|
||||
None. All Sprint 13 server tickets done.
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #323 | NPC mood state machine | — |
|
||||
| #324 | Trust progression system | — |
|
||||
| #325 | Interaction tracking component | — |
|
||||
| #101 | Routine execution system | #87 done, #238 done |
|
||||
| #103 | Relationship dynamics | — |
|
||||
| #247 | NPC-to-NPC conversation system | — |
|
||||
| #508 | Map-agnostic invariant tests (36 invariants) | — |
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/architecture.md` — D-024 (10-axis NPC model), D-026 (simulation tiers — ActiveSim scope), D-031 (game clock / day phases — routine transitions), D-041 (knowledge graph — trust feeds Layer 3 filtering), D-054 (tile-based movement), D-075 (dialogue filtering — trust co-gate with KnowledgeConfidence)
|
||||
- `decisions/content.md` — D-023 (three-tier content model), D-028 (dialogue four-layer model — Layers 2-4 fed by trust/interaction tracking), D-029 (population entanglement ratio — 30/50/20), D-034 (THE FRIEND — trust arc requirements), D-062 (invisible locked options — new trust tier = new options appear silently)
|
||||
- `decisions/perception.md` — D-016 (internal monologue — mood drives monologue tone), D-018 (three-range sound model — NPC-to-NPC conversations emit Voice events)
|
||||
|
||||
## Notes
|
||||
|
||||
- **#323 — NPC mood state machine:** Add a `Mood` component to `server/src/npc/mod.rs`. Eight moods for v0.1: `Neutral`, `Anxious`, `Frustrated`, `Content`, `Suspicious`, `Warm`, `Hostile`, `Focused` (9th: added Sprint 8, D-035 amendment). Mood changes based on: triangle pressure (high ToleranceThreshold stress → Anxious/Hostile), time of day (Evening + long shift → Frustrated), recent interactions (positive player interaction → Warm). Write a `MoodSystem` in a new `server/src/npc/mood.rs` module. Mood is read by the monologue trigger system (`server/src/simulation/monologue.rs`) and will gate Layer 4 dialogue selection (`server/src/simulation/dialogue.rs`). Blocks #337 (tell state derivation, deferred to S15). Add to `NpcPlugin` in `server/src/npc/mod.rs`. Keep all values `i16`/enum — no floats, per D-010 determinism.
|
||||
- Delivery: `Mood` component, `update_mood` system, unit tests in `server/src/npc/mood.rs`.
|
||||
|
||||
- **#324 — Trust progression system:** Trust level per player-NPC pair drives D-028 Layer 3 (trust-gated gossip). The `RelationshipGraph` at `server/src/npc/relationships.rs` already holds `trust: i8` on every `RelationshipEdge`. Sprint 14 work: (1) a system that advances trust based on interaction quality — `TalkVerb` completion → small positive increment, confrontation walk-away → negative decrement, repeat visit (from #325 interaction count) → small positive; (2) expose the current trust value on `ObserverSnapshot` or leave it in the KnowledgeGraph (D-041). Trust maps to D-028 TrustTier via `relationship_to_trust()` in `server/src/simulation/dialogue.rs` — D-075 adds KnowledgeConfidence as co-gate to that function. No new ECS component needed — `RelationshipGraph` resource is the store. Write a `update_trust` system in `server/src/npc/relationships.rs`. Blocks #171 (Layer 3 trust-gated gossip, deferred to S15).
|
||||
- Integration point: `server/src/simulation/dialogue.rs` `process_talk_interaction` must call `relationship_to_trust()` with `KnowledgeConfidence` (D-075 implementation if not already wired).
|
||||
- Delivery: `update_trust` system, tests verifying trust increments/decrements on interaction events.
|
||||
|
||||
- **#325 — Interaction tracking component:** `InteractionMemory` component per NPC-pair — fields: `interaction_count: u32`, `last_interaction_tick: u64`, `notable_events: Vec<InteractionEvent>`. The `interaction_count` drives D-028 Layer 2 situation activation: `first_meeting` when count == 0, `repeated_visit` when count >= 3. Add to `server/src/npc/mod.rs` or a new `server/src/npc/interaction.rs`. Populated by `process_talk_interaction` in `server/src/simulation/dialogue.rs` each time a Talk verb completes. `notable_events` stores walk-aways (D-064) and confrontations (D-063) — these are already recorded in the KG but `InteractionMemory` provides fast per-pair access without a full KG query.
|
||||
- Integration point: `server/src/simulation/dialogue.rs` — increment count and stamp tick on each completed Talk. `dialogue.rs` already reads KG and RelationshipGraph; add InteractionMemory to the same query.
|
||||
- Delivery: `InteractionMemory` component, incremented by dialogue system, used by situation resolver for Layer 2.
|
||||
|
||||
- **#101 — Routine execution system:** The routine data model exists (`DailyRoutine`, `RoutineEntry` in `server/src/npc/mod.rs`). The phase-transition trigger exists (`check_phase_transition` in `server/src/npc/routine.rs`) — it already issues `PathRequest` on phase change. Sprint 14 work: verify the full execution loop closes — `PathRequest` → pathfinder → `path_follow` → entity reaches destination and enters the routine activity state. Add an `activity: String` → `ActivityState` ECS component (or reuse `DailyRoutine.entries[].activity`) so the simulation knows what an NPC is currently doing. This feeds the `DuringActivity` tell trigger (`TellTrigger::DuringActivity` in `server/src/npc/mod.rs`) and the Layer 2 `situation` tag matching. Existing pathfinding: `server/src/simulation/pathfinding.rs`, `server/src/simulation/path_follow.rs`. Gauntlet room `shift_change_*` (Sprint 13 #505) already validates phase-boundary transition — extend it or write new integration tests for full routine loop.
|
||||
- Delivery: `ActivityState` component (or equivalent) attached/updated as NPCs execute routines; tests verifying NPC reaches routine destination and holds activity state.
|
||||
|
||||
- **#103 — Relationship dynamics:** Relationship values decay and reinforce over time. Existing `RelationshipEdge` in `server/src/npc/relationships.rs` has `trust: i8` and `history: Vec<RelationshipEvent>`. Sprint 14 work: (1) passive decay — trust drifts toward 0 at ~1 point per game-day if no recent interaction (controlled by `last_interaction_tick`); (2) interaction reinforcement — talking, helping (future), witnessing positive events strengthens; (3) `RelationshipEvent` is appended on notable interactions. This system runs in the Background tier (D-026) — lightweight, 1 update per game-minute. Add `update_relationship_dynamics` system to `server/src/npc/relationships.rs`, registered in `NpcPlugin`. Blocks #249 (player-action social propagation, deferred to S15).
|
||||
- Note: Do not confuse with #324 (player-NPC trust). #103 covers NPC-NPC relationship dynamics — the relationship graph that generates the social texture (#249 feeds into). The player-NPC trust arc (THE FRIEND, #324) is a separate ticket.
|
||||
|
||||
- **#247 — NPC-to-NPC conversation system:** NPCs in the Active tier who are in proximity (≤3 tiles) and share a social site occasionally enter NPC-to-NPC conversations. Implementation: (1) a `ConversationSystem` in `server/src/simulation/` that detects eligible NPC pairs (same zone, ActiveSim, not already in player conversation, not sprinting away), initiates a `NpcConversation` state component for the pair, and emits a `SoundEvent` of kind `Voice` at the conversation tile — picked up by `server/src/simulation/sound.rs` and included in `ObserverSnapshot.sound_events`; (2) conversation duration in ticks (configurable constant, ~30-120 ticks = 3-12 game-minutes); (3) conversation terminates when one NPC leaves the zone or duration expires. The client's `sound_indicator_renderer.gd` already handles `Voice` sound events. This is the first source of overheard conversations — the eavesdrop mechanic (D-426 `ListeningFocus`) becomes meaningful when NPCs are actually talking.
|
||||
- **D-078 addition (amended — occlusion is SERVER-AUTHORITATIVE):** Each `ConversationEvent` on `ObserverSnapshot` must carry: `occluded_line: String` (the NPC dialogue line with dropped words replaced by `...`), `speaker_id: EntityId`, `target_id: EntityId`, `speaker_name: String`, `target_name: String`. The server performs per-word occlusion before emission — the client receives pre-occluded text and renders it verbatim.
|
||||
- **Per-word occlusion algorithm:** Iterate the dialogue line word by word. For each word, perform an independent Bernoulli trial using a seedable RNG (seeded per tick for deterministic replay, D-010). Per-word drop probability is derived from three inputs at the moment of emission: (a) tile distance from player to the speaking NPC — linear decay from 0.0 drop probability at 0 tiles to 1.0 at the `Voice` sound range boundary; (b) ambient noise level at the player position (already tracked in `ObserverSnapshot.ambient_noise`) — adds up to 0.3 to drop probability; (c) whether the player entity has `ListeningFocus` stance active — subtracts 0.2 from drop probability (clamped to [0.0, 1.0]). Words that fail the trial are replaced with `...` in `occluded_line`.
|
||||
- When a conversation ends (NPC departs zone or duration expires), emit a `conversation_end` event with the same pair IDs so the client can dismiss the panel. Trigger `witness_interaction` on the player observation pipeline whenever the player receives any conversation event (regardless of occlusion level).
|
||||
- No dialogue content needed in this ticket — content is sourced from the NPC-to-NPC line pool (#536, copy team, Sprint 14).
|
||||
- Delivery: `NpcConversation` state component, `run_npc_conversations` system, `Voice` `SoundEvent` emission, `ConversationEvent` struct with `occluded_line` on `ObserverSnapshot`, `conversation_end` event, per-word occlusion function with seedable RNG, tests: `npc_conversation_emits_voice_event_when_in_range`, `npc_conversation_terminates_when_apart`, `occlusion_drops_words_with_distance`, `occlusion_suppressed_by_listening_focus`, `occlusion_deterministic_with_same_seed`. Blocks #535 (client).
|
||||
|
||||
- **#508 — Map-agnostic invariant tests (36 invariants):** 36 invariants across 4 categories from Gestalt's workshop output. Implement as a test suite in `server/src/test_world/` that runs against any valid map (gauntlet rooms). Categories: structural (8 — tile counts, wall connectivity, spawn point validity), perception (5 — LOS symmetry at range, sound range boundaries), population (8 — NPC count limits, tier assignment correctness), simulation (8 — no entity teleports, determinism, pathfinder termination, interaction buffer clear on sprint). Add a `run_invariants(world: &World)` function callable from gauntlet room tests — each room calls it after setup to assert structural invariants hold. Dynamic map system not required — invariants work on static gauntlet maps.
|
||||
- Note: #509 (fuzzy tests for procedural maps) remains deferred — requires dynamic map generation.
|
||||
- Delivery: `server/src/test_world/invariants.rs` module with 36 test assertions; each gauntlet room test calls `run_invariants`.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#323 (mood) ─────────────────────────────────────→ #337 (tell derivation, S15)
|
||||
#324 (trust) ────────────────────────────────────→ #171 (Layer 3, S15)
|
||||
#325 (interaction tracking) → #101 (routine) is parallel
|
||||
→ #103 (relationship dynamics) is parallel
|
||||
→ #247 (NPC conversations) is parallel
|
||||
#247 (NPC conversations) ────────────────────────→ #535 (client passive panel, blocked)
|
||||
#508 (invariants) — standalone, parallel with all above
|
||||
```
|
||||
|
||||
All six simulation tickets (#323, #324, #325, #101, #103, #247) are independent of each other and can proceed in parallel. #508 is also fully standalone. #535 (client) is blocked on #247 landing the `ConversationEvent` struct on `ObserverSnapshot` — specifically `occluded_line: String` (pre-occluded by server), `speaker_name`, `target_name`, and `conversation_end`.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(simulation): description" --description "body" --base main --head server
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
# Sprint 14: Live — Visual Tasks
|
||||
|
||||
**Goal:** Bring NPCs to life — mood, trust, routine execution, and NPC-to-NPC conversation drive the first emergent social observables; the simulation produces a world that breathes independently of the player.
|
||||
|
||||
**Branch:** `visual`
|
||||
**Agents:** Araminta (art direction)
|
||||
|
||||
## Carry-over from Sprint 13
|
||||
|
||||
None. All Sprint 13 visual tickets done (#315 monologue display spec).
|
||||
|
||||
## New Tickets
|
||||
|
||||
| # | Title | Blocked by |
|
||||
|---|-------|------------|
|
||||
| #304 | Entity Color System Spec — relationship-to-player mapping | #303 done |
|
||||
| #316 | Text display hierarchy spec — 4 content pipelines | #303 done |
|
||||
| #317 | Sound indicator visual design — fog-edge pulse | #303 done |
|
||||
| #318 | THE FRIEND visual treatment spec | #303 done, #297 done, #298 done |
|
||||
| #334 | Environmental text visual standards | #303 done |
|
||||
| #251 | Tell visual/behavioral expression | — |
|
||||
|
||||
All six tickets are unblocked. All are spec/design documents — Araminta authors; no code deliverables from the visual branch this sprint.
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full details.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- `decisions/content.md` — D-033 (entity color = relationship to player — approved color hex values: unknown=teal #4a9ebb, known/friendly=green #6bc9a6, person-of-interest=amber #e8c547, hostile=red #d45d5d, static objects=grey #8090a8), D-034 (THE FRIEND — phase 1 identical to other friendly NPCs, earned visual detail only), D-043 (art direction — functional warmth), D-044 (visual hierarchy — entity > object > structure), D-045 (environmental neutrality — zero shift with narrative state), D-046 (lighting system — three-reference model), D-047 (two-tier animation system — Tier 1 readable, Tier 2 ambiguous), D-048 (neural insert overlay), D-049 (z-level rendering stack — 8 layers)
|
||||
- `decisions/perception.md` — D-016 (internal monologue — text rendering), D-017 (perception modes), D-018 (three-range sound model — color codes for sound indicator: neutral #c8d0e0, voices #e8c547, danger #d45d5d), D-035 (tag taxonomy — monologue display spec references character field), D-062 (invisible locked dialogue — no lock icons, no hints)
|
||||
- `decisions/scope.md` — D-027 (vertical slice success criteria — #3: player names an NPC they felt conflicted about = THE FRIEND)
|
||||
|
||||
## Notes
|
||||
|
||||
- **#304 — Entity Color System Spec:** Araminta formalizes the approved D-033 color model into a full spec document. Output: `docs/design/entity-color-system.md`. Required sections:
|
||||
- Relationship state → color mapping table (hex values confirmed in D-033 are the canonical source).
|
||||
- How relationship state is determined at render time: client receives `relationship: RelationshipState` on each `VisibleEntity` in the snapshot (`server/src/bridge/types.rs` `VisibleEntity`). Client maps state to color via a lookup table in `entity_renderer.gd` (`client/scripts/rendering/entity_renderer.gd`).
|
||||
- Transition behavior: when an entity's relationship state changes (e.g., PersonOfInterest after confrontation), how the color shift renders — instant or tween, duration.
|
||||
- Static objects: always `#8090a8` regardless of any relationship state. Define what counts as a static object (fixtures, furniture, terminals) vs entity (NPCs, player, carried items).
|
||||
- Edge cases: player character color (not subject to relationship coloring — different treatment), entities in fog (D-033 color does NOT show through fog — unrecognized fog blobs are grey until cognitive delay resolves), entities at periphery (reduced saturation per D-046 lighting reference model).
|
||||
- Color blindness note: D-033 colors were selected for functional warmth — include Araminta's assessment of whether the teal/green/amber/red set is distinguishable under deuteranopia and protan deficiency. Flag if adjustment needed.
|
||||
- This spec is the direct input to the client-side implementation (future sprint — no client ticket this sprint). Must be self-contained enough for Stig to implement from the doc alone.
|
||||
|
||||
- **#316 — Text display hierarchy spec:** How the four content pipelines render distinctly. Output: `docs/design/text-display-hierarchy.md`. Four pipelines:
|
||||
1. **Dialogue** (NPC speech + player response options): bottom panel, max 20% height, max ~100 chars per line (D-061). Speaker name styled. Response options styled differently from speech.
|
||||
2. **Internal monologue** (player character): left-side or bottom-left, italic, smaller font, character colour from #315 spec (done Sprint 13).
|
||||
3. **Observation / overheard** (what the player perceives about NPCs or overhears): distinct treatment from monologue — diegetic information output, not character voice. Consider: greyed label, different font weight, positioned differently.
|
||||
4. **Environmental text** (signage, terminals, news tickers in-world): diegetic — appears in the world layer, not the HUD layer. Two-language treatment per D-036 (Concordat Standard + Krenn vernacular). Character limits per format (signage shorter, terminal longer, news ticker scrolling).
|
||||
- Each pipeline needs: position on screen, font size/weight, colour scheme, max width/height, fade/truncation behaviour. D-049 z-layer assignments for each.
|
||||
- Reference: `docs/design/monologue-display-spec.md` (#315, done) for monologue pipeline specifics — this spec extends and cross-references it.
|
||||
|
||||
- **#317 — Sound indicator visual design:** Fog-edge pulse indicators for the D-018 three-range sound model. Output: `docs/design/sound-indicator-visual.md`. Design requirements (from ticket and D-018):
|
||||
- **Purpose:** Complementary to audio, not replacement. Indicates sound presence for players with audio off or in loud environments.
|
||||
- **Colors:** neutral #c8d0e0 (footsteps, ambient), voices #e8c547 (NPC conversation), danger #d45d5d (alarms, alerts).
|
||||
- **Position:** Fog edge — where the visible area meets unexplored/deep fog. Not a minimap overlay, not a screen-edge indicator. Appears at the boundary tiles of the player's visible cone.
|
||||
- **Shape and animation:** Pulse — how many pixels, what frequency, what easing, what opacity range. Must not be distracting during exploration; must be noticeable when specifically a threat.
|
||||
- **Direction encoding:** Indicates direction the sound comes from — how? Arc segment on fog edge in the direction of source? Size variation? Specify clearly for Stig's implementation.
|
||||
- **Range differentiation:** Close sounds (≤3 tiles) produce larger/brighter pulses; medium (≤8 tiles) moderate; long (≤20 tiles) subtle. Define the three visual levels.
|
||||
- **When it does not appear:** When audio is playing and the sound is within the visible area (no need to indicate what you can already see/hear). Rules for suppression.
|
||||
- The `sound_indicator_renderer.gd` file exists at `client/scripts/rendering/sound_indicator_renderer.gd` — Araminta should note what it currently does vs what the spec calls for.
|
||||
|
||||
- **#318 — THE FRIEND visual treatment spec:** How Kael Davan (smuggler's FRIEND) and Sera Venn (detective's FRIEND) look different from other NPCs through earned visual detail only — no special marking. Output: `docs/design/the-friend-visual-treatment.md`. Per D-034 design principle: Phase 1 identical to other friendly NPCs (green rectangle per D-033). Differentiation accrues through story, not through marking. Required sections:
|
||||
- **Phase 1 (before player builds relationship):** Identical to any other Known/Friendly NPC. Green rectangle, no distinguishing visual.
|
||||
- **Phase 2 (after 3+ interactions, trust building):** What subtle visual shift occurs — if any — that is diegetically justified, not metatextual marking. Examples: does the character carry an item that renders (a manifest, a specific color? per D-033 object color rules)? Does their routine placement become more predictable visually? Define what "earned visual detail" means concretely.
|
||||
- **Phase 3 (contradiction discovered — PersonOfInterest):** Transition to amber #e8c547 per D-033. Monologue firing rate spikes. Spec: when does the color shift? Immediately on confrontation? On player delivery of contradiction knowledge? Tween duration.
|
||||
- **Animation tier:** THE FRIEND is in Tier 2 animation (ambiguous, privately motivated behaviors per D-047) once the player has seen their contradiction. Before contradiction: Tier 1. Spec the transition point.
|
||||
- Cross-reference `docs/design/entity-color-system.md` (#304, this sprint) once it is drafted.
|
||||
|
||||
- **#334 — Environmental text visual standards:** How signage, terminals, and news tickers render in-world. Output: `docs/design/environmental-text-standards.md`. Per ticket:
|
||||
- **Signage:** Short (1-3 words typical), both Concordat Standard and Krenn vernacular. Character limits. Font size relative to tile size (D-066: 1m visual tiles, 2x retina factor). Position: floating above the tile, or rendered on the tile surface.
|
||||
- **Terminals:** Longer text, readable on interaction (Observe verb). Two-state: ambient (icon/identifier visible from range) and active (text readable when player is adjacent).
|
||||
- **News tickers:** Scrolling text, ambient, not blocking gameplay. Where on screen — in-world (floating above terminal), not HUD overlay.
|
||||
- **Bilingual treatment:** Concordat Standard is the colonial lingua franca (neutral, bureaucratic). Krenn vernacular is the local dialect (warm, compact, consonant-heavy per D-036 naming conventions). Which text in which language per context? Formal signage = Concordat Standard. Informal social text (bar menu, worker notices) = Krenn vernacular. Mixed where both audiences are intended.
|
||||
- **Rendering layer:** Per D-049 z-layer stack — environmental text is object-layer (layer 2-3), not HUD layer (layer 6+). Must not occlude entities.
|
||||
|
||||
- **#251 — Tell visual/behavioral expression:** How NPC behavioral tells manifest in the top-down renderer. Output: `docs/design/tell-visual-expression.md`. Per D-024 tell system (5 categories: nervous, angry, friendly, guarded, routine deviation) and server's `TellSystem` component (`server/src/npc/mod.rs`). In v0.1, tell expression is via **monologue text** (server emits monologue on observe_npc trigger when tell is active), not visual animation — this is confirmed. However, Araminta's task is to define:
|
||||
- What observable behaviors in the top-down renderer accompany tells — movement hesitation (NPC pauses before entering a room), route changes (NPC takes alternate path), grouping behavior (NPC lingers near another), interaction changes (NPC avoids certain tiles).
|
||||
- These are **behavioral patterns in the simulation** described visually — the spec tells Dudley what the tell should look like at the tile level, which he implements as movement/pathfinding modifiers. The spec is the design input to server-side tell behavior.
|
||||
- Per D-047 animation tier: Tier 2 animation is "ambiguous, privately motivated behaviors." Tells are the canonical Tier 2 examples — movement hesitation is a Tier 2 animation. Define which of the 5 tell categories maps to which Tier 2 behaviors.
|
||||
- Araminta's scope here: the visual description. Dudley implements the simulation behavior. Mellanie writes the monologue that accompanies the tell.
|
||||
|
||||
## Dependency Chain
|
||||
|
||||
```
|
||||
#304 (entity color spec) ─────────────────→ #318 (THE FRIEND visual, cross-ref)
|
||||
#316 (text display hierarchy) ───────────→ standalone
|
||||
#317 (sound indicator visual) ───────────→ standalone
|
||||
#318 (THE FRIEND visual) ────────────────→ #251 (tell visual, cross-ref)
|
||||
#334 (environmental text) ───────────────→ standalone
|
||||
#251 (tell visual) ──────────────────────→ standalone
|
||||
```
|
||||
|
||||
Recommended order: start #304 (entity color) first — #318 cross-references it. #251 and #318 can proceed in parallel once #304 is drafted. The other three (#316, #317, #334) are fully independent and can proceed in any order.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
When ready to submit, create a PR with `tea` CLI. All flags are required to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
|
||||
|
||||
```bash
|
||||
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "docs(visual): description" --description "body" --base main --head visual
|
||||
```
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: The Settled Reach
|
||||
version: 0.1.12
|
||||
version: 0.1.13
|
||||
repository: settled-reach
|
||||
codename: commonwealth
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1092,7 +1092,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -268,6 +268,7 @@ mod tests {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: None,
|
||||
}],
|
||||
nearby_interactions: vec![NearbyInteraction {
|
||||
entity_id: 100,
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 10;
|
||||
pub const PROTOCOL_VERSION: u8 = 11;
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
/// Contains all information visible to the observer at a given tick.
|
||||
@@ -30,10 +30,11 @@ pub const PROTOCOL_VERSION: u8 = 10;
|
||||
/// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities).
|
||||
/// v10 adds: sound_events (#124, D-038 server sound event pipeline),
|
||||
/// rng_seed (#527, deterministic replay — completes WRONG button loop).
|
||||
/// v11 adds: zone_id on VisibleTile (#523, D-077 OQ-09 resolution + D-073 crossfade).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 10.
|
||||
/// Protocol version for forward compatibility. Current: 11.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -209,6 +210,13 @@ pub struct VisibleTile {
|
||||
/// Tile type for client rendering (floor, wall, door, object)
|
||||
#[serde(default)]
|
||||
pub tile_kind: TileKind,
|
||||
/// Zone identifier for this tile (D-077 OQ-09, D-073 crossfade).
|
||||
/// Server-authoritative zone assignment. Client maps zone_id to:
|
||||
/// - Audio crossfade target (D-073)
|
||||
/// - Deep fog temperature tint (D-059 layer 3)
|
||||
/// None for tiles outside any defined zone (corridors, transition spaces).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub zone_id: Option<u16>,
|
||||
}
|
||||
|
||||
/// Tile type for rendering. Derived from WalkabilityMap on the server side.
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::sound::SoundEventQueue;
|
||||
use crate::simulation::stance::Stance;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
|
||||
/// Compute visibility geometry using the active perception mode.
|
||||
/// Stage 1 of the observer pipeline: FOV + vision cone → VisibilityGeometry.
|
||||
@@ -61,6 +62,7 @@ pub fn compute_observer_snapshot(
|
||||
time: Res<SimulationTime>,
|
||||
geometry: Res<VisibilityGeometry>,
|
||||
registry: Res<EntityRegistry>,
|
||||
zone_map: Option<Res<ZoneMap>>,
|
||||
sound_queue: Option<Res<SoundEventQueue>>,
|
||||
mut observer_query: Query<
|
||||
(
|
||||
@@ -221,6 +223,19 @@ pub fn compute_observer_snapshot(
|
||||
// Sort entities by entity_id for deterministic snapshot ordering (#457)
|
||||
entities.sort_by_key(|e| e.entity_id);
|
||||
|
||||
// Enrich tiles with zone_id from ZoneMap (D-077, D-073)
|
||||
let visible_tiles = match zone_map.as_deref() {
|
||||
Some(zm) => geometry
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.map(|t| VisibleTile {
|
||||
zone_id: zm.zone_at(t.x, t.y, t.z),
|
||||
..t.clone()
|
||||
})
|
||||
.collect(),
|
||||
None => geometry.visible_tiles.clone(),
|
||||
};
|
||||
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
||||
tick: time.tick,
|
||||
@@ -229,7 +244,7 @@ pub fn compute_observer_snapshot(
|
||||
player_stance: stance_opt.map(|s| s.0).unwrap_or_default(),
|
||||
player_inventory,
|
||||
entities,
|
||||
visible_tiles: geometry.visible_tiles.clone(),
|
||||
visible_tiles,
|
||||
nearby_interactions,
|
||||
current_monologue,
|
||||
pending_recognitions,
|
||||
|
||||
@@ -2466,3 +2466,116 @@ fn blocked_entities_sorted_ascending() {
|
||||
assert!(snapshot.blocked_entities.contains(&npc_b_sid.0));
|
||||
assert!(snapshot.blocked_entities.contains(&npc_c_sid.0));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Zone enrichment tests (#523, D-077)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn zone_map_enriches_visible_tiles_with_zone_id() {
|
||||
// Tile inside a zone should get zone_id = Some(zone_id)
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut zone_map = ZoneMap::default();
|
||||
// Zone 42 covers (14..18, 14..18) — includes the observer's tile at (16,16)
|
||||
zone_map.set_rect(14, 14, 4, 4, 0, 42);
|
||||
world.insert_resource(zone_map);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
// Observer's tile (16, 16) is inside zone 42
|
||||
let observer_tile = snapshot
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.find(|t| t.x == 16 && t.y == 16 && t.z == 0)
|
||||
.expect("observer tile should be visible");
|
||||
assert_eq!(
|
||||
observer_tile.zone_id,
|
||||
Some(42),
|
||||
"tile inside zone should have zone_id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_map_tiles_outside_zone_get_none() {
|
||||
// Tile outside any zone should get zone_id = None
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut zone_map = ZoneMap::default();
|
||||
// Zone only covers (0..2, 0..2) — far from observer at (16,16)
|
||||
zone_map.set_rect(0, 0, 2, 2, 0, 7);
|
||||
world.insert_resource(zone_map);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
// Observer's tile (16, 16) is outside any zone
|
||||
let observer_tile = snapshot
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.find(|t| t.x == 16 && t.y == 16 && t.z == 0)
|
||||
.expect("observer tile should be visible");
|
||||
assert_eq!(
|
||||
observer_tile.zone_id, None,
|
||||
"tile outside any zone should have zone_id = None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_zone_map_resource_tiles_have_no_zone_id() {
|
||||
// When ZoneMap resource is absent, all tiles should have zone_id = None
|
||||
let mut world = setup_world(32, 32);
|
||||
// Do NOT insert ZoneMap resource
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
assert!(
|
||||
!snapshot.visible_tiles.is_empty(),
|
||||
"should have visible tiles"
|
||||
);
|
||||
// All tiles should have zone_id = None when no ZoneMap exists
|
||||
for tile in &snapshot.visible_tiles {
|
||||
assert_eq!(
|
||||
tile.zone_id, None,
|
||||
"tile ({},{}) should have zone_id = None without ZoneMap resource",
|
||||
tile.x, tile.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ impl PerceptionQuery for NaturalVision {
|
||||
z,
|
||||
visibility: sector,
|
||||
tile_kind,
|
||||
zone_id: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod sound;
|
||||
pub mod stance;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
pub mod zone;
|
||||
|
||||
/// Core simulation plugin
|
||||
/// Manages simulation time, RNG, input processing, and tier transitions
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Zone map — spatial zone assignment for tiles (D-077, D-073).
|
||||
//!
|
||||
//! Maps tile coordinates to zone identifiers. Used by:
|
||||
//! - Observer snapshot: enriches VisibleTile with zone_id
|
||||
//! - Client AudioManager: zone crossfade triggers (D-073)
|
||||
//! - Client fog shader: deep fog temperature tint (D-059 layer 3)
|
||||
//!
|
||||
//! BTreeMap per D-010 principle 4 (deterministic iteration).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
/// Server-authoritative zone assignment for tiles.
|
||||
///
|
||||
/// Each tile position maps to a zone ID. Tiles outside any defined zone
|
||||
/// (corridors, transition spaces) have no entry and return None.
|
||||
///
|
||||
/// Zone IDs are opaque u16 values — the client maintains its own
|
||||
/// `zone_id → zone_name / temperature_tint / ambient_layer` mapping.
|
||||
///
|
||||
/// Production population path is TBD — currently populated only by
|
||||
/// Gauntlet room builders via `set_rect` / `set`.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ZoneMap {
|
||||
zones: BTreeMap<(i32, i32, i32), u16>,
|
||||
}
|
||||
|
||||
impl ZoneMap {
|
||||
/// Look up the zone for a tile position.
|
||||
pub fn zone_at(&self, x: i32, y: i32, z: i32) -> Option<u16> {
|
||||
self.zones.get(&(x, y, z)).copied()
|
||||
}
|
||||
|
||||
/// Assign a zone to a rectangular region of tiles.
|
||||
/// Used by map builders to define zone boundaries.
|
||||
pub fn set_rect(&mut self, ox: i32, oy: i32, w: i32, h: i32, z: i32, zone_id: u16) {
|
||||
for y in oy..(oy + h) {
|
||||
for x in ox..(ox + w) {
|
||||
self.zones.insert((x, y, z), zone_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assign a zone to a single tile.
|
||||
pub fn set(&mut self, x: i32, y: i32, z: i32, zone_id: u16) {
|
||||
self.zones.insert((x, y, z), zone_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zone_at_returns_none_for_unset_tile() {
|
||||
let map = ZoneMap::default();
|
||||
assert_eq!(map.zone_at(10, 20, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_rect_populates_zone() {
|
||||
let mut map = ZoneMap::default();
|
||||
map.set_rect(5, 10, 3, 2, 0, 42);
|
||||
|
||||
// Inside rect
|
||||
assert_eq!(map.zone_at(5, 10, 0), Some(42));
|
||||
assert_eq!(map.zone_at(7, 11, 0), Some(42));
|
||||
|
||||
// Outside rect
|
||||
assert_eq!(map.zone_at(4, 10, 0), None);
|
||||
assert_eq!(map.zone_at(8, 10, 0), None);
|
||||
assert_eq!(map.zone_at(5, 12, 0), None);
|
||||
|
||||
// Wrong z-level
|
||||
assert_eq!(map.zone_at(5, 10, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_single_tile() {
|
||||
let mut map = ZoneMap::default();
|
||||
map.set(3, 7, 0, 99);
|
||||
assert_eq!(map.zone_at(3, 7, 0), Some(99));
|
||||
assert_eq!(map.zone_at(3, 8, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,61 @@ pub const CONFRONTATION_STAGE: GauntletRoom = GauntletRoom {
|
||||
}),
|
||||
};
|
||||
|
||||
/// Sound Lab — Room 12 (34x20)
|
||||
/// Tests D-018 (three-range sound model). Sound-emitting NPCs at calibrated
|
||||
/// distances from the observer: Close (2 tiles), Medium (6 tiles), Long (12 tiles).
|
||||
pub const SOUND_LAB: GauntletRoom = GauntletRoom {
|
||||
name: "sound_lab",
|
||||
origin: TilePosition {
|
||||
x: 0,
|
||||
y: 104,
|
||||
z: 0,
|
||||
},
|
||||
size: (34, 20),
|
||||
spawn: TilePosition {
|
||||
x: 10,
|
||||
y: 114,
|
||||
z: 0,
|
||||
},
|
||||
observer: TilePosition {
|
||||
x: 10,
|
||||
y: 114,
|
||||
z: 0,
|
||||
},
|
||||
observer_facing: Facing(FacingDirection::East),
|
||||
reset_plate: Some(TilePosition {
|
||||
x: 16,
|
||||
y: 103,
|
||||
z: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
/// Decay Observatory — Room 13 (24x14)
|
||||
/// Tests D-041 knowledge graph decay. NPC observed in LOS then LOS broken;
|
||||
/// confidence degrades Direct → KnowsDetails → KnowsOf → Suspects over ticks.
|
||||
pub const DECAY_OBSERVATORY: GauntletRoom = GauntletRoom {
|
||||
name: "decay_observatory",
|
||||
origin: TilePosition { x: 0, y: 24, z: 0 },
|
||||
size: (24, 14),
|
||||
spawn: TilePosition { x: 10, y: 30, z: 0 },
|
||||
observer: TilePosition { x: 10, y: 30, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::East),
|
||||
reset_plate: Some(TilePosition { x: 15, y: 23, z: 0 }),
|
||||
};
|
||||
|
||||
/// Shift Change — Room 14 (16x24)
|
||||
/// Tests D-031 day-phase transitions. Two NPCs with DailyRoutine components
|
||||
/// receive PathRequests when game clock crosses a phase boundary.
|
||||
pub const SHIFT_CHANGE: GauntletRoom = GauntletRoom {
|
||||
name: "shift_change",
|
||||
origin: TilePosition { x: 64, y: 78, z: 0 },
|
||||
size: (16, 24),
|
||||
spawn: TilePosition { x: 72, y: 90, z: 0 },
|
||||
observer: TilePosition { x: 72, y: 90, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::North),
|
||||
reset_plate: Some(TilePosition { x: 72, y: 78, z: 0 }),
|
||||
};
|
||||
|
||||
/// All rooms in canonical spawn order.
|
||||
/// THIS ORDER DETERMINES STABLEID ASSIGNMENT.
|
||||
/// Do not reorder existing entries. Append new rooms at the end.
|
||||
@@ -215,6 +270,9 @@ pub const ROOMS: &[GauntletRoom] = &[
|
||||
SPRINT_GAUNTLET,
|
||||
EAVESDROP_ALCOVE,
|
||||
CONFRONTATION_STAGE,
|
||||
SOUND_LAB,
|
||||
DECAY_OBSERVATORY,
|
||||
SHIFT_CHANGE,
|
||||
];
|
||||
|
||||
/// Look up which room a position falls in.
|
||||
@@ -259,6 +317,12 @@ pub const EAVESDROP_ALCOVE_STABLE_IDS: (u64, u64) = (58, 60);
|
||||
pub const CONFRONTATION_STAGE_STABLE_IDS: (u64, u64) = (61, 62);
|
||||
/// Reset plates for Sprint 11 rooms (sprint_gauntlet, eavesdrop_alcove, confrontation_stage).
|
||||
pub const SPRINT11_RESET_PLATE_STABLE_IDS: (u64, u64) = (63, 65);
|
||||
// Sprint 13 rooms — appended after SPRINT11_RESET_PLATE_STABLE_IDS per additive-only rule.
|
||||
pub const SOUND_LAB_STABLE_IDS: (u64, u64) = (66, 68);
|
||||
pub const DECAY_OBSERVATORY_STABLE_IDS: (u64, u64) = (69, 69);
|
||||
pub const SHIFT_CHANGE_STABLE_IDS: (u64, u64) = (70, 71);
|
||||
/// Reset plates for Sprint 13 rooms (sound_lab, decay_observatory, shift_change).
|
||||
pub const SPRINT13_RESET_PLATE_STABLE_IDS: (u64, u64) = (72, 74);
|
||||
|
||||
/// Number of actively-spawned entities in the current Gauntlet build.
|
||||
/// Derived from StableId ranges of all rooms + player + reset plates.
|
||||
@@ -275,7 +339,11 @@ pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
|
||||
+ (SPRINT_GAUNTLET_STABLE_IDS.1 - SPRINT_GAUNTLET_STABLE_IDS.0 + 1) as usize
|
||||
+ (EAVESDROP_ALCOVE_STABLE_IDS.1 - EAVESDROP_ALCOVE_STABLE_IDS.0 + 1) as usize
|
||||
+ (CONFRONTATION_STAGE_STABLE_IDS.1 - CONFRONTATION_STAGE_STABLE_IDS.0 + 1) as usize
|
||||
+ (SPRINT11_RESET_PLATE_STABLE_IDS.1 - SPRINT11_RESET_PLATE_STABLE_IDS.0 + 1) as usize;
|
||||
+ (SPRINT11_RESET_PLATE_STABLE_IDS.1 - SPRINT11_RESET_PLATE_STABLE_IDS.0 + 1) as usize
|
||||
+ (SOUND_LAB_STABLE_IDS.1 - SOUND_LAB_STABLE_IDS.0 + 1) as usize
|
||||
+ (DECAY_OBSERVATORY_STABLE_IDS.1 - DECAY_OBSERVATORY_STABLE_IDS.0 + 1) as usize
|
||||
+ (SHIFT_CHANGE_STABLE_IDS.1 - SHIFT_CHANGE_STABLE_IDS.0 + 1) as usize
|
||||
+ (SPRINT13_RESET_PLATE_STABLE_IDS.1 - SPRINT13_RESET_PLATE_STABLE_IDS.0 + 1) as usize;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -384,7 +452,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_rooms_in_correct_order() {
|
||||
assert_eq!(ROOMS.len(), 11);
|
||||
assert_eq!(ROOMS.len(), 14);
|
||||
assert_eq!(ROOMS[0].name, "central_hub");
|
||||
assert_eq!(ROOMS[1].name, "fog_theater");
|
||||
assert_eq!(ROOMS[2].name, "occlusion_corridor");
|
||||
@@ -396,6 +464,9 @@ mod tests {
|
||||
assert_eq!(ROOMS[8].name, "sprint_gauntlet");
|
||||
assert_eq!(ROOMS[9].name, "eavesdrop_alcove");
|
||||
assert_eq!(ROOMS[10].name, "confrontation_stage");
|
||||
assert_eq!(ROOMS[11].name, "sound_lab");
|
||||
assert_eq!(ROOMS[12].name, "decay_observatory");
|
||||
assert_eq!(ROOMS[13].name, "shift_change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -458,6 +529,10 @@ mod tests {
|
||||
EAVESDROP_ALCOVE_STABLE_IDS,
|
||||
CONFRONTATION_STAGE_STABLE_IDS,
|
||||
SPRINT11_RESET_PLATE_STABLE_IDS,
|
||||
SOUND_LAB_STABLE_IDS,
|
||||
DECAY_OBSERVATORY_STABLE_IDS,
|
||||
SHIFT_CHANGE_STABLE_IDS,
|
||||
SPRINT13_RESET_PLATE_STABLE_IDS,
|
||||
];
|
||||
for (i, a) in ranges.iter().enumerate() {
|
||||
for (j, b) in ranges.iter().enumerate() {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! - Entities spawned in canonical order → StableId assignment is deterministic
|
||||
//! - Additive-only: existing rooms/entities never reordered
|
||||
//!
|
||||
//! StableId ranges (from gestalt-round3.md + Sprint 11):
|
||||
//! StableId ranges (from gestalt-round3.md + Sprint 11 + Sprint 13):
|
||||
//! Player: 0
|
||||
//! Hub signs: 1-4
|
||||
//! Fog Theater: 5-8
|
||||
@@ -31,6 +31,10 @@
|
||||
//! Eavesdrop Alcove: 58-60
|
||||
//! Confrontation Stage: 61-62
|
||||
//! Reset plates (Sprint 11 rooms): 63-65
|
||||
//! Sound Lab: 66-68
|
||||
//! Decay Observatory: 69
|
||||
//! Shift Change: 70-71
|
||||
//! Reset plates (Sprint 13 rooms): 72-74
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod constants;
|
||||
@@ -64,6 +68,8 @@ use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomal
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::zone::ZoneMap;
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use reset::{RoomResetTrigger, RoomSnapshots};
|
||||
@@ -101,6 +107,10 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
carve_room_interior(&mut walkability, 0, 2, 28, 20); // Sprint Gauntlet
|
||||
carve_room_interior(&mut walkability, 74, 26, 24, 16); // Eavesdrop Alcove
|
||||
carve_room_interior(&mut walkability, 84, 2, 32, 24); // Confrontation Stage
|
||||
// Sprint 13 rooms
|
||||
carve_room_interior(&mut walkability, 0, 104, 34, 20); // Sound Lab
|
||||
carve_room_interior(&mut walkability, 0, 24, 24, 14); // Decay Observatory
|
||||
carve_room_interior(&mut walkability, 64, 78, 16, 24); // Shift Change
|
||||
|
||||
// Carve corridors between hub and rooms
|
||||
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
|
||||
@@ -130,6 +140,22 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
|
||||
app.insert_resource(walkability);
|
||||
|
||||
// Zone map: assign zone IDs per room bounding box (D-077, D-073).
|
||||
// Zone IDs are sequential per ROOMS order. Corridors remain unzoned (None).
|
||||
let mut zone_map = ZoneMap::default();
|
||||
for (i, room) in constants::ROOMS.iter().enumerate() {
|
||||
let zone_id = i as u16;
|
||||
zone_map.set_rect(
|
||||
room.origin.x,
|
||||
room.origin.y,
|
||||
room.size.0,
|
||||
room.size.1,
|
||||
room.origin.z,
|
||||
zone_id,
|
||||
);
|
||||
}
|
||||
app.insert_resource(zone_map);
|
||||
|
||||
// Entity spawning in canonical StableId order.
|
||||
// Player gets StableId 0, then entities by room in workshop order.
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
@@ -256,6 +282,15 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
// --- Confrontation Stage (StableId 61-62) ---
|
||||
rooms::confrontation_stage::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Sound Lab (StableId 66-68) ---
|
||||
rooms::sound_lab::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Decay Observatory (StableId 69) ---
|
||||
rooms::decay_observatory::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Shift Change (StableId 70-71) ---
|
||||
rooms::shift_change::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Sprint 11 reset plates (StableId 63-65) ---
|
||||
let sprint11_reset_plates: &[(&str, TilePosition)] = &[
|
||||
(
|
||||
@@ -294,6 +329,44 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
// --- Sprint 13 reset plates (StableId 72-74) ---
|
||||
let sprint13_reset_plates: &[(&str, TilePosition)] = &[
|
||||
(
|
||||
"sound_lab",
|
||||
constants::SOUND_LAB
|
||||
.reset_plate
|
||||
.expect("sound_lab should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"decay_observatory",
|
||||
constants::DECAY_OBSERVATORY
|
||||
.reset_plate
|
||||
.expect("decay_observatory should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"shift_change",
|
||||
constants::SHIFT_CHANGE
|
||||
.reset_plate
|
||||
.expect("shift_change should have a reset_plate"),
|
||||
),
|
||||
];
|
||||
for &(room_name, pos) in sprint13_reset_plates {
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: room_name.to_string(),
|
||||
},
|
||||
pos,
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
// --- Populate RoomSnapshots for reset mechanism (#490) ---
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
|
||||
@@ -390,6 +463,35 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sound Lab entities (StableId 66-68): NPCs only, no floor items
|
||||
for id in constants::SOUND_LAB_STABLE_IDS.0..=constants::SOUND_LAB_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("sound_lab", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decay Observatory entities (StableId 69): NPC only, no floor items
|
||||
for id in
|
||||
constants::DECAY_OBSERVATORY_STABLE_IDS.0..=constants::DECAY_OBSERVATORY_STABLE_IDS.1
|
||||
{
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("decay_observatory", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shift Change entities (StableId 70-71): NPCs only, no floor items
|
||||
for id in constants::SHIFT_CHANGE_STABLE_IDS.0..=constants::SHIFT_CHANGE_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("shift_change", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.insert_resource(snapshots);
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
@@ -624,5 +726,45 @@ mod tests {
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Sound Lab at 66-68
|
||||
for id in constants::SOUND_LAB_STABLE_IDS.0..=constants::SOUND_LAB_STABLE_IDS.1 {
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Sound Lab at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Decay Observatory at 69
|
||||
for id in constants::DECAY_OBSERVATORY_STABLE_IDS.0
|
||||
..=constants::DECAY_OBSERVATORY_STABLE_IDS.1
|
||||
{
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Decay Observatory at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Shift Change at 70-71
|
||||
for id in constants::SHIFT_CHANGE_STABLE_IDS.0..=constants::SHIFT_CHANGE_STABLE_IDS.1 {
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Shift Change at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Sprint 13 reset plates at 72-74
|
||||
for id in constants::SPRINT13_RESET_PLATE_STABLE_IDS.0
|
||||
..=constants::SPRINT13_RESET_PLATE_STABLE_IDS.1
|
||||
{
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Sprint 13 reset plate at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Decay Observatory — Room 13 (24x14)
|
||||
//!
|
||||
//! Tests D-041 (knowledge graph decay). Observer sees an NPC (Direct), then
|
||||
//! LOS is broken (drops to KnowsDetails). Tick advances verify confidence
|
||||
//! degrades: KnowsDetails → KnowsOf → Suspects (floor). Separate test
|
||||
//! confirms Stale state triggers when age exceeds stale_after threshold.
|
||||
//!
|
||||
//! Decay runs once per game-minute (every 10 ticks, D-031). Confidence floor
|
||||
//! is Suspects — decay never removes an entity from the knowledge graph.
|
||||
//!
|
||||
//! Layout: Open room. Single observable NPC east of observer.
|
||||
//! This room sits in the corridor-NW path (x=12-18, y=22-40); the corridor
|
||||
//! creates natural north/south doorways through the room walls.
|
||||
//!
|
||||
//! Observer position: (10, 30) absolute, facing East.
|
||||
//!
|
||||
//! Entities (StableId 69):
|
||||
//! npc_observe_target (16, 30) — 6 tiles east of observer, starts in LOS
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 0;
|
||||
const ORIGIN_Y: i32 = 24;
|
||||
|
||||
/// Observer position for Decay Observatory tests (absolute).
|
||||
pub const OBSERVER_POS: TilePosition = TilePosition { x: 10, y: 30, z: 0 };
|
||||
|
||||
/// NPC position — 6 tiles east of observer, starts in direct LOS.
|
||||
pub const NPC_OBSERVE_POS: TilePosition = TilePosition { x: 16, y: 30, z: 0 };
|
||||
|
||||
/// Spawn Decay Observatory entities in canonical order (StableId 69).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
let pos = TilePosition::new(ORIGIN_X + 16, ORIGIN_Y + 6, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 4,
|
||||
description: "Decay Observatory: observable NPC".to_string(),
|
||||
},
|
||||
Contentment { level: 5 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 60,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::types::{DecayThresholds, KnowledgeConfidence, KnowledgeState, StableId};
|
||||
|
||||
const TARGET: StableId = StableId(1);
|
||||
|
||||
fn observe_then_leave(tick_observe: u64, tick_leave: u64) -> KnowledgeGraph {
|
||||
let mut g = KnowledgeGraph::new();
|
||||
g.observe_entity(TARGET, NPC_OBSERVE_POS, tick_observe);
|
||||
g.observe_entity_leaving_los(&TARGET, tick_leave);
|
||||
g
|
||||
}
|
||||
|
||||
/// After LOS loss, confidence degrades one level per decay pass:
|
||||
/// KnowsDetails → KnowsOf → Suspects (floor).
|
||||
#[test]
|
||||
fn decay_observatory_confidence_degrades_after_los_loss() {
|
||||
let mut g = observe_then_leave(100, 110);
|
||||
|
||||
// Verify starting state: KnowsDetails after LOS loss.
|
||||
assert_eq!(
|
||||
g.confidence_of(&TARGET),
|
||||
Some(KnowledgeConfidence::KnowsDetails),
|
||||
"confidence must be KnowsDetails immediately after LOS loss"
|
||||
);
|
||||
|
||||
let thresholds = DecayThresholds {
|
||||
decay_after: 10,
|
||||
stale_after: 10_000,
|
||||
};
|
||||
|
||||
// Tick 200: age = 200 - 100 = 100 > decay_after=10 → KnowsOf
|
||||
g.decay(200, &thresholds);
|
||||
assert_eq!(
|
||||
g.confidence_of(&TARGET),
|
||||
Some(KnowledgeConfidence::KnowsOf),
|
||||
"KnowsDetails must decay to KnowsOf after one decay pass"
|
||||
);
|
||||
|
||||
// Tick 300: age still > decay_after → Suspects
|
||||
g.decay(300, &thresholds);
|
||||
assert_eq!(
|
||||
g.confidence_of(&TARGET),
|
||||
Some(KnowledgeConfidence::Suspects),
|
||||
"KnowsOf must decay to Suspects after second decay pass"
|
||||
);
|
||||
|
||||
// Tick 400: Suspects is the floor — no further degradation.
|
||||
g.decay(400, &thresholds);
|
||||
assert_eq!(
|
||||
g.confidence_of(&TARGET),
|
||||
Some(KnowledgeConfidence::Suspects),
|
||||
"Suspects is the confidence floor — decay must not go below Suspects"
|
||||
);
|
||||
}
|
||||
|
||||
/// After exceeding the stale threshold the entry state becomes Stale.
|
||||
#[test]
|
||||
fn decay_observatory_stale_after_threshold() {
|
||||
let mut g = observe_then_leave(100, 110);
|
||||
|
||||
let thresholds = DecayThresholds {
|
||||
decay_after: 10,
|
||||
stale_after: 50,
|
||||
};
|
||||
|
||||
// Tick 200: age = 200 - 100 = 100 > stale_after=50 → Stale
|
||||
g.decay(200, &thresholds);
|
||||
|
||||
let entry = g.entity_knowledge(&TARGET).unwrap();
|
||||
assert_eq!(
|
||||
entry.state,
|
||||
KnowledgeState::Stale,
|
||||
"entry state must become Stale when age exceeds stale_after threshold"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub mod confrontation_stage;
|
||||
pub mod crowd_plaza;
|
||||
pub mod decay_observatory;
|
||||
pub mod dialogue_room;
|
||||
pub mod eavesdrop_alcove;
|
||||
pub mod fog_theater;
|
||||
@@ -13,4 +14,6 @@ pub mod interaction_gallery;
|
||||
pub mod inventory_warehouse;
|
||||
pub mod occlusion_corridor;
|
||||
pub mod pause_chamber;
|
||||
pub mod shift_change;
|
||||
pub mod sound_lab;
|
||||
pub mod sprint_gauntlet;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Shift Change — Room 14 (16x24)
|
||||
//!
|
||||
//! Tests D-031 (day phases) and NPC routine transitions at phase boundaries.
|
||||
//! Two NPCs have DailyRoutine components with entries for Morning and Afternoon.
|
||||
//! Advancing game clock to the Afternoon boundary triggers PathRequests for
|
||||
//! both NPCs (they must move to their Afternoon locations).
|
||||
//!
|
||||
//! This room sits adjacent to the corridor-E2 path (x=58-80, y=84-90), which
|
||||
//! provides natural east-west access through the room interior.
|
||||
//!
|
||||
//! Observer position: (72, 90) absolute, facing North.
|
||||
//!
|
||||
//! Entities (StableId 70-71):
|
||||
//! npc_shift_morning (70, 84) — Morning location: 6 tiles north-west of observer
|
||||
//! npc_shift_afternoon (76, 96) — Afternoon location: 4 tiles east, 6 south of observer
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, DailyRoutine, Npc, RoutineEntry, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::DayPhase;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 64;
|
||||
const ORIGIN_Y: i32 = 78;
|
||||
|
||||
/// Observer position for Shift Change tests (absolute).
|
||||
pub const OBSERVER_POS: TilePosition = TilePosition { x: 72, y: 90, z: 0 };
|
||||
|
||||
/// Morning routine location for both NPCs — where they stand during Morning phase.
|
||||
pub const MORNING_LOCATION: TilePosition = TilePosition { x: 70, y: 84, z: 0 };
|
||||
|
||||
/// Afternoon routine location — where NPCs should walk to at Afternoon boundary.
|
||||
pub const AFTERNOON_LOCATION: TilePosition = TilePosition { x: 76, y: 96, z: 0 };
|
||||
|
||||
/// Spawn Shift Change entities in canonical order (StableId 70-71).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
// npc_shift_morning (StableId 70): starts at Morning location, has both Morning + Afternoon entries.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
MORNING_LOCATION,
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 4,
|
||||
description: "Shift Change: morning shift NPC".to_string(),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 5,
|
||||
threshold: 45,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: MORNING_LOCATION,
|
||||
activity: "Morning station".to_string(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: AFTERNOON_LOCATION,
|
||||
activity: "Afternoon patrol".to_string(),
|
||||
},
|
||||
],
|
||||
description: "Shifts between morning station and afternoon patrol".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
|
||||
// npc_shift_afternoon (StableId 71): starts at Afternoon location, same routine.
|
||||
// Starting position differs from Morning location so a transition always triggers a PathRequest.
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
AFTERNOON_LOCATION,
|
||||
Want {
|
||||
primary: WantKind::Connection,
|
||||
intensity: 3,
|
||||
description: "Shift Change: afternoon shift NPC".to_string(),
|
||||
},
|
||||
Contentment { level: 2 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 10,
|
||||
threshold: 55,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: MORNING_LOCATION,
|
||||
activity: "Morning briefing".to_string(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: AFTERNOON_LOCATION,
|
||||
activity: "Afternoon post".to_string(),
|
||||
},
|
||||
],
|
||||
description: "Covers afternoon post, returns to morning briefing".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::routine::{check_phase_transition, PreviousDayPhase};
|
||||
use crate::simulation::pathfinding::PathRequest;
|
||||
use crate::simulation::time::{SimulationTime, MINUTES_PER_PHASE, TICKS_PER_GAME_MINUTE};
|
||||
|
||||
/// At the Morning→Afternoon boundary, NPCs receive PathRequests for their Afternoon location.
|
||||
#[test]
|
||||
fn shift_change_npc_transitions_routine_at_phase_boundary() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<PreviousDayPhase>();
|
||||
|
||||
// Spawn npc_shift_morning: currently at MORNING_LOCATION.
|
||||
// On Afternoon transition it should receive a PathRequest to AFTERNOON_LOCATION.
|
||||
let npc_morning = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MORNING_LOCATION,
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: MORNING_LOCATION,
|
||||
activity: "Morning station".to_string(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: AFTERNOON_LOCATION,
|
||||
activity: "Afternoon patrol".to_string(),
|
||||
},
|
||||
],
|
||||
description: "Test routine".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// Spawn npc_shift_afternoon: currently at AFTERNOON_LOCATION.
|
||||
// Already at Afternoon location — PathRequest should NOT fire for it.
|
||||
let npc_afternoon = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
AFTERNOON_LOCATION,
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: AFTERNOON_LOCATION,
|
||||
activity: "Afternoon post".to_string(),
|
||||
}],
|
||||
description: "Test routine".to_string(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// Advance time to Afternoon boundary (Morning→Afternoon).
|
||||
world.resource_mut::<SimulationTime>().tick =
|
||||
MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_phase_transition);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// npc_shift_morning must have received a PathRequest to AFTERNOON_LOCATION.
|
||||
let req = world
|
||||
.get::<PathRequest>(npc_morning)
|
||||
.expect("npc_shift_morning must receive PathRequest at Afternoon boundary");
|
||||
assert_eq!(
|
||||
req.goal, AFTERNOON_LOCATION,
|
||||
"PathRequest goal must be the Afternoon routine location"
|
||||
);
|
||||
|
||||
// npc_shift_afternoon is already at AFTERNOON_LOCATION — no PathRequest.
|
||||
assert!(
|
||||
world.get::<PathRequest>(npc_afternoon).is_none(),
|
||||
"npc_shift_afternoon already at Afternoon location — must NOT receive PathRequest"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Sound Lab — Room 12 (34x20)
|
||||
//!
|
||||
//! Tests D-018 (three-range sound model). Sound-emitting entities placed at
|
||||
//! calibrated Manhattan distances from the observer. Verifies that:
|
||||
//! - Close (≤3 tiles): SoundEvent is audible at observer
|
||||
//! - Medium (≤8 tiles): SoundEvent is audible with Medium range but NOT Close
|
||||
//! - Long (≤20 tiles): SoundEvent is audible with Long range but NOT Medium
|
||||
//!
|
||||
//! Layout: Observer at west interior, emitters placed east at increasing
|
||||
//! distances along the same row.
|
||||
//!
|
||||
//! Observer position: (10, 114) absolute, facing East.
|
||||
//!
|
||||
//! Entities (StableId 66-68):
|
||||
//! npc_sound_close (12, 114) — 2 tiles east of observer (Close range, dist ≤3)
|
||||
//! npc_sound_medium (16, 114) — 6 tiles east of observer (Medium range, dist ≤8)
|
||||
//! npc_sound_long (22, 114) — 12 tiles east of observer (Long range, dist ≤20)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 0;
|
||||
const ORIGIN_Y: i32 = 104;
|
||||
|
||||
/// Observer position for Sound Lab tests (absolute).
|
||||
pub const OBSERVER_POS: TilePosition = TilePosition { x: 10, y: 114, z: 0 };
|
||||
|
||||
/// Close-range emitter position — 2 tiles east of observer (dist=2, Close ≤3).
|
||||
pub const CLOSE_EMITTER_POS: TilePosition = TilePosition { x: 12, y: 114, z: 0 };
|
||||
|
||||
/// Medium-range emitter position — 6 tiles east of observer (dist=6, Medium ≤8, outside Close).
|
||||
pub const MEDIUM_EMITTER_POS: TilePosition = TilePosition { x: 16, y: 114, z: 0 };
|
||||
|
||||
/// Long-range emitter position — 12 tiles east of observer (dist=12, Long ≤20, outside Medium).
|
||||
pub const LONG_EMITTER_POS: TilePosition = TilePosition { x: 22, y: 114, z: 0 };
|
||||
|
||||
/// NPC definitions: (relative_x, relative_y, want_kind, intensity).
|
||||
const NPCS: &[(i32, i32, WantKind, u8)] = &[
|
||||
(12, 10, WantKind::Safety, 3), // npc_sound_close — StableId 66
|
||||
(16, 10, WantKind::Knowledge, 4), // npc_sound_medium — StableId 67
|
||||
(22, 10, WantKind::Freedom, 5), // npc_sound_long — StableId 68
|
||||
];
|
||||
|
||||
/// Spawn Sound Lab entities in canonical order (StableId 66-68).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(rx, ry, want_kind, intensity) in NPCS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity,
|
||||
description: "Sound Lab test emitter".to_string(),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 50,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::types::SoundRange;
|
||||
use crate::simulation::sound::{SoundEvent, SoundEventKind};
|
||||
|
||||
/// Close emitter is 2 tiles away — must be audible with SoundRange::Close (≤3 tiles, D-018).
|
||||
#[test]
|
||||
fn sound_lab_close_range_in_snapshot() {
|
||||
let event = SoundEvent::at(
|
||||
&CLOSE_EMITTER_POS,
|
||||
SoundEventKind::Footstep,
|
||||
0.8,
|
||||
SoundRange::Close,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
event.audible_at(&OBSERVER_POS),
|
||||
"Close emitter at dist=2 must be audible with SoundRange::Close (max 3 tiles)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Medium emitter is 6 tiles away — audible with Medium (≤8) but NOT with Close (≤3).
|
||||
#[test]
|
||||
fn sound_lab_medium_range_indicator_present() {
|
||||
let medium_event = SoundEvent::at(
|
||||
&MEDIUM_EMITTER_POS,
|
||||
SoundEventKind::Voice,
|
||||
0.6,
|
||||
SoundRange::Medium,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
medium_event.audible_at(&OBSERVER_POS),
|
||||
"Medium emitter at dist=6 must be audible with SoundRange::Medium (max 8 tiles)"
|
||||
);
|
||||
|
||||
// Negative: Close range cannot reach 6 tiles.
|
||||
let close_event = SoundEvent::at(
|
||||
&MEDIUM_EMITTER_POS,
|
||||
SoundEventKind::Voice,
|
||||
0.6,
|
||||
SoundRange::Close,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
!close_event.audible_at(&OBSERVER_POS),
|
||||
"Medium-position emitter (dist=6) must NOT be audible with SoundRange::Close (max 3 tiles)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Long emitter is 12 tiles away — audible with Long (≤20) but NOT with Medium (≤8).
|
||||
#[test]
|
||||
fn sound_lab_long_range_insert_only() {
|
||||
let long_event = SoundEvent::at(
|
||||
&LONG_EMITTER_POS,
|
||||
SoundEventKind::Alert,
|
||||
0.9,
|
||||
SoundRange::Long,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
long_event.audible_at(&OBSERVER_POS),
|
||||
"Long emitter at dist=12 must be audible with SoundRange::Long (max 20 tiles)"
|
||||
);
|
||||
|
||||
// Negative: Medium range cannot reach 12 tiles.
|
||||
let medium_event = SoundEvent::at(
|
||||
&LONG_EMITTER_POS,
|
||||
SoundEventKind::Alert,
|
||||
0.9,
|
||||
SoundRange::Medium,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
!medium_event.audible_at(&OBSERVER_POS),
|
||||
"Long-position emitter (dist=12) must NOT be audible with SoundRange::Medium (max 8 tiles)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,7 @@ fn generate_msgpack_fixtures() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: Some(1),
|
||||
},
|
||||
VisibleTile {
|
||||
x: 11,
|
||||
@@ -195,6 +196,7 @@ fn generate_msgpack_fixtures() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Peripheral,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: Some(1),
|
||||
},
|
||||
VisibleTile {
|
||||
x: 10,
|
||||
@@ -202,6 +204,7 @@ fn generate_msgpack_fixtures() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: None,
|
||||
},
|
||||
],
|
||||
nearby_interactions: vec![],
|
||||
|
||||
@@ -241,6 +241,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: None,
|
||||
},
|
||||
VisibleTile {
|
||||
x: 6,
|
||||
@@ -248,6 +249,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Peripheral,
|
||||
tile_kind: TileKind::Wall,
|
||||
zone_id: None,
|
||||
},
|
||||
],
|
||||
nearby_interactions: vec![],
|
||||
@@ -312,7 +314,7 @@ fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 10,
|
||||
PROTOCOL_VERSION, 11,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
@@ -862,6 +864,7 @@ fn boundary_value_in_tile_position() {
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
zone_id: None,
|
||||
}];
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot)
|
||||
.unwrap_or_else(|e| panic!("encode tile x/y={} failed: {}", tile_val, e));
|
||||
@@ -1264,6 +1267,83 @@ fn v9_payload_deserializes_into_v10_struct() {
|
||||
);
|
||||
}
|
||||
|
||||
/// v10 payload (without zone_id on VisibleTile) deserializes into the v11 struct
|
||||
/// via #[serde(default)]. Guards backwards compat during migration (#523, D-077).
|
||||
#[test]
|
||||
fn v10_payload_deserializes_into_v11_struct() {
|
||||
// V10 VisibleTile: no zone_id field
|
||||
#[derive(serde::Serialize)]
|
||||
struct VisibleTileV10 {
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
visibility: VisibilitySector,
|
||||
tile_kind: TileKind,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ObserverSnapshotV10 {
|
||||
version: u8,
|
||||
tick: u64,
|
||||
game_time: GameTime,
|
||||
player_facing: FacingDirection,
|
||||
player_stance: MovementStance,
|
||||
player_inventory: Vec<InventoryItem>,
|
||||
entities: Vec<VisibleEntity>,
|
||||
visible_tiles: Vec<VisibleTileV10>,
|
||||
nearby_interactions: Vec<NearbyInteraction>,
|
||||
current_monologue: Option<MonologueEvent>,
|
||||
pending_recognitions: Vec<PendingRecognitionWire>,
|
||||
dialogue_response: Option<DialogueResponseEvent>,
|
||||
blocked_entities: Vec<u64>,
|
||||
scan_events: Vec<settled_reach_server::simulation::contraband::ScanEvent>,
|
||||
sound_events: Vec<settled_reach_server::simulation::sound::SoundEvent>,
|
||||
rng_seed: Option<u64>,
|
||||
}
|
||||
|
||||
let v10 = ObserverSnapshotV10 {
|
||||
version: 10,
|
||||
tick: 300,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
player_stance: MovementStance::Walk,
|
||||
player_inventory: vec![],
|
||||
entities: vec![],
|
||||
visible_tiles: vec![VisibleTileV10 {
|
||||
x: 5,
|
||||
y: 10,
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
}],
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
rng_seed: Some(42),
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&v10).expect("serialize v10");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.expect("v10 payload should deserialize into v11 struct via serde(default)");
|
||||
|
||||
assert_eq!(decoded.version, 10, "version field preserved from v10");
|
||||
assert_eq!(decoded.tick, 300);
|
||||
assert_eq!(decoded.visible_tiles.len(), 1);
|
||||
assert_eq!(
|
||||
decoded.visible_tiles[0].zone_id, None,
|
||||
"missing zone_id should default to None"
|
||||
);
|
||||
}
|
||||
|
||||
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
|
||||
/// Verifies object_type=Some(Container) survives the wire.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user