fix: resolve merge conflicts with main (Situation + Mood variants)
Keep all new variants from both branches: Greeting (copy), FirstMeeting and RepeatedVisit (server). Combine Mood doc comments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,18 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Monologue display system visual spec — typography, positioning, stacking, priority, fade animation, character color differentiation, 80-char line constraint (#315)
|
||||
- Entity color system spec — D-033 relationship-to-player mapping, transition animations, color blindness assessment (#304)
|
||||
- Text display hierarchy spec — 4 content pipelines (dialogue, monologue, observation, environmental) with z-layers and positioning (#316)
|
||||
- Sound indicator visual design — fog-edge pulse for D-018 three-range sound model with direction encoding and range differentiation (#317)
|
||||
- THE FRIEND visual treatment spec — 3-phase earned visual detail for Kael Davan and Sera Venn (#318)
|
||||
- Environmental text visual standards — signage, terminal, and news ticker rendering with bilingual Concordat/Krenn treatment (#334)
|
||||
- Tell visual/behavioral expression spec — 5 tell categories mapped to 6 Tier 2 behaviors (#251)
|
||||
|
||||
### Fixed
|
||||
- Visual grammar dialogue max-width corrected from "~70% screen width" to 640px per D-076
|
||||
|
||||
## [v0.1.13] — 2026-02-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -23,7 +23,12 @@ var player_entity_id: int = 1
|
||||
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
|
||||
|
||||
# v5 fields (#414)
|
||||
var current_monologue: Variant = null # {id, text, duration_seconds} or null
|
||||
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
|
||||
|
||||
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
|
||||
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
|
||||
# Server sends this field as part of the player's capability snapshot.
|
||||
var lattice_profile: String = "lattice_baseline"
|
||||
|
||||
# v6 fields (#449, D-053, D-065)
|
||||
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
|
||||
@@ -138,6 +143,10 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
current_monologue = null
|
||||
|
||||
# #122: lattice_profile — character insert capability level for monologue colour
|
||||
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
|
||||
lattice_profile = snapshot.lattice_profile
|
||||
|
||||
# v6: player_stance (#449, D-053)
|
||||
if snapshot.has("player_stance") and snapshot.player_stance is String:
|
||||
player_stance = snapshot.player_stance
|
||||
|
||||
+17
-4
@@ -19,8 +19,9 @@ extends Node2D
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _camera_anchored: bool = false
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
||||
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
||||
var _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
|
||||
@@ -288,7 +289,12 @@ func _consume_monologue() -> void:
|
||||
return
|
||||
_last_monologue_tick = GameState.current_tick
|
||||
var mono: Dictionary = GameState.current_monologue
|
||||
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
|
||||
monologue_display.show_monologue(
|
||||
mono.get("text", ""),
|
||||
mono.get("duration_seconds", 5.0),
|
||||
mono.get("priority", 2),
|
||||
mono.get("is_urgent", false)
|
||||
)
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
@@ -296,6 +302,7 @@ func _consume_monologue() -> void:
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
|
||||
# Consume-once per tick with ID tracking: show dialogue, then clear.
|
||||
# Tick guard + is_dialogue_active check prevent re-triggering.
|
||||
func _consume_dialogue() -> void:
|
||||
@@ -331,9 +338,15 @@ func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
|
||||
|
||||
# D-063: Handle confrontation beat monologue → show on monologue display (layer 7)
|
||||
# Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour.
|
||||
# Tick guard deduplicates if dialogue box emits the signal multiple times in one tick.
|
||||
func _on_confrontation_monologue(text: String, duration: float) -> void:
|
||||
if monologue_display:
|
||||
monologue_display.show_monologue(text, duration)
|
||||
if not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_confrontation_tick:
|
||||
return
|
||||
_last_confrontation_tick = GameState.current_tick
|
||||
monologue_display.show_monologue(text, duration, 3, true)
|
||||
|
||||
|
||||
# D-064: Handle walk-away → send WalkAway{npc_id} to server
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
## #122: Monologue display — client tests (Sprint 14)
|
||||
## Covers queue management, priority logic, stagger, colour palette, BBCode output,
|
||||
## no-overwrite contract (P0 #477), and per-slot fade lifecycle.
|
||||
##
|
||||
## API per Tyre architecture review:
|
||||
## show_monologue(text, duration, priority=2, is_urgent=false)
|
||||
## GameState.lattice_profile selects colour palette
|
||||
class_name TestMonologueDisplay
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _make_display() -> Node:
|
||||
if not ResourceLoader.exists("res://ui/monologue_display.tscn"):
|
||||
push_warning("TestMonologueDisplay: scene not found — tests skipped")
|
||||
return null
|
||||
var node = load("res://ui/monologue_display.tscn").instantiate()
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _label_text(d: Node) -> String:
|
||||
if d._visible.is_empty():
|
||||
return ""
|
||||
var slot_node: Node = d._visible[0].node
|
||||
return (slot_node.get_child(0) as RichTextLabel).text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle — ensure clean GameState between every test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func before_test() -> void:
|
||||
## Reset GameState fields touched by this suite so tests don't bleed into each other.
|
||||
## lattice_profile: tests that care about colour set it explicitly — default to baseline.
|
||||
## current_monologue: GameState integration tests need null as start state.
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initial state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_nothing_visible_on_init() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
assert_int(d._visible.size()).is_equal(0)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_stagger_timer_zero_on_init() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
assert_float(d._next_fade_in_msec).is_equal(0.0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_show_monologue_with_empty_text_does_not_set_displaying() -> void:
|
||||
# Empty text must be ignored — no visible slot created, no queue entry.
|
||||
# Prevents ghost display nodes and stagger timer contamination.
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(0)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_empty_text_does_not_advance_stagger_timer() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("", 5.0)
|
||||
assert_float(d._next_fade_in_msec).is_equal(0.0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_empty_text_when_slots_full_does_not_enqueue() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
d.show_monologue("", 5.0)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-line display
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_single_line_goes_to_visible() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("One.", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(1)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_show_monologue_sets_stagger_timer() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
var before := float(Time.get_ticks_msec())
|
||||
d.show_monologue("Stagger.", 5.0)
|
||||
assert_float(d._next_fade_in_msec).is_greater(before)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAX_VISIBLE = 3 simultaneous lines
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_three_lines_all_visible() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
assert_int(d._visible.size()).is_equal(3)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_fourth_line_queues_when_slots_full() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
d.show_monologue("D", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(3)
|
||||
assert_int(d._queue.size()).is_equal(1)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No-overwrite contract (P0, #477)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_new_line_does_not_replace_first_visible_line() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("First line.", 10.0)
|
||||
var first_text := _label_text(d)
|
||||
# Force stagger active; second call must queue, not display
|
||||
d._next_fade_in_msec = float(Time.get_ticks_msec()) + 10000.0
|
||||
d.show_monologue("Second line.", 5.0)
|
||||
assert_that(_label_text(d)).is_equal(first_text)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_two_visible_lines_coexist_without_overwriting() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("Alpha", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("Beta", 10.0)
|
||||
assert_int(d._visible.size()).is_equal(2)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Priority queue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_queue_sorted_highest_priority_first() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
d.show_monologue("low", 5.0, 1)
|
||||
d.show_monologue("high", 5.0, 4)
|
||||
d.show_monologue("normal", 5.0, 2)
|
||||
assert_int(d._queue[0].priority).is_equal(4)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_queue_drop_replaces_lowest_when_full() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
for i in range(d.MAX_QUEUE):
|
||||
d.show_monologue("low_%d" % i, 5.0, 1)
|
||||
d.show_monologue("critical!", 5.0, 9)
|
||||
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
|
||||
var has_critical := false
|
||||
for e in d._queue:
|
||||
if e.priority == 9:
|
||||
has_critical = true
|
||||
assert_that(has_critical).is_true()
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_queue_ignores_lower_priority_when_full() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
for i in range(d.MAX_QUEUE):
|
||||
d.show_monologue("hi_%d" % i, 5.0, 5)
|
||||
d.show_monologue("noise", 1.0, 1)
|
||||
assert_int(d._queue.size()).is_equal(d.MAX_QUEUE)
|
||||
for e in d._queue:
|
||||
assert_int(e.priority).is_equal(5)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_queue_never_exceeds_max_depth() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
for i in range(d.MAX_QUEUE + 20):
|
||||
d.show_monologue("flood_%d" % i, 1.0, 2)
|
||||
assert_int(d._queue.size()).is_less_or_equal(d.MAX_QUEUE)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expire and drain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_expired_slot_removed_from_visible() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Expires.", 1.0)
|
||||
d._visible[0].expire_timer = -0.1
|
||||
d._process(0.0)
|
||||
assert_int(d._visible.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_queue_drains_when_slot_opens() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("A", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("B", 10.0)
|
||||
d._next_fade_in_msec = 0.0; d.show_monologue("C", 10.0)
|
||||
d.show_monologue("Queued.", 5.0)
|
||||
d._visible[0].expire_timer = -0.1
|
||||
d._next_fade_in_msec = 0.0 # stagger elapsed
|
||||
d._process(0.0)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
assert_int(d._visible.size()).is_equal(3)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stagger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_second_call_within_stagger_period_queues() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("First.", 5.0)
|
||||
# _next_fade_in_msec is now ~150ms in the future
|
||||
d.show_monologue("Second.", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(1)
|
||||
assert_int(d._queue.size()).is_equal(1)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_stagger_elapsed_allows_second_visible() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("First.", 5.0)
|
||||
d._next_fade_in_msec = 0.0 # manually expire stagger
|
||||
d.show_monologue("Second.", 5.0)
|
||||
assert_int(d._visible.size()).is_equal(2)
|
||||
assert_int(d._queue.size()).is_equal(0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BBCode output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_text_wrapped_in_italic_bbcode() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Italic line.", 5.0)
|
||||
var txt := _label_text(d)
|
||||
assert_that(txt).contains("[i]")
|
||||
assert_that(txt).contains("[/i]")
|
||||
assert_that(txt).contains("Italic line.")
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_text_has_color_bbcode() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Coloured.", 5.0)
|
||||
var txt := _label_text(d)
|
||||
assert_that(txt).contains("[color=#")
|
||||
assert_that(txt).contains("[/color]")
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lattice colour palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_augmented_colour_differs_from_baseline() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_augmented"
|
||||
d.show_monologue("Detective.", 5.0)
|
||||
var aug_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
d._next_fade_in_msec = 0.0
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Smuggler.", 5.0)
|
||||
var base_txt := _label_text(d)
|
||||
|
||||
assert_that(aug_txt).is_not_equal(base_txt)
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_urgent_colour_differs_from_standard() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Normal.", 5.0, 2, false)
|
||||
var std_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
d._next_fade_in_msec = 0.0
|
||||
|
||||
d.show_monologue("Urgent!", 5.0, 3, true)
|
||||
var urg_txt := _label_text(d)
|
||||
|
||||
assert_that(std_txt).is_not_equal(urg_txt)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_unknown_profile_falls_back_without_crash() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
GameState.lattice_profile = "lattice_hypothetical_tier_x"
|
||||
d.show_monologue("Future proof.", 5.0)
|
||||
var txt := _label_text(d)
|
||||
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_visible_slot_has_tween() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Has tween.", 5.0)
|
||||
assert_that(d._visible[0].tween).is_not_null()
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_visible_slot_stores_priority() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Priority 7.", 5.0, 7)
|
||||
assert_int(d._visible[0].priority).is_equal(7)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_expire_timer_decrements_in_process() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
d.show_monologue("Timer.", 10.0)
|
||||
d._process(1.5)
|
||||
assert_float(d._visible[0].expire_timer).is_less(10.0)
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameState integration — current_monologue field (v5, #414)
|
||||
# These tests do not instantiate the scene — they verify apply_snapshot()
|
||||
# correctly populates and clears current_monologue so the display layer
|
||||
# receives valid data (or null) every tick.
|
||||
#
|
||||
# Joint test plan (sprint-14/joint.md §Test Plan Alignment):
|
||||
# "Verify current_monologue: None produces no display (no ghost text from previous tick)"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_gamestate_monologue_null_when_snapshot_omits_field() -> void:
|
||||
## D-016: Snapshot without current_monologue must clear the field.
|
||||
## Prevents a stale line from a previous tick persisting as ghost text.
|
||||
GameState.current_monologue = {"id": "stale", "text": "Old.", "duration_seconds": 3.0}
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.current_monologue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_monologue_set_from_snapshot() -> void:
|
||||
## apply_snapshot populates current_monologue when the field is a dict.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "m001", "text": "A thought.", "duration_seconds": 5.0},
|
||||
})
|
||||
assert_that(GameState.current_monologue).is_not_null()
|
||||
assert_that(GameState.current_monologue.get("text")).is_equal("A thought.")
|
||||
|
||||
|
||||
func test_gamestate_monologue_null_when_field_is_non_dict() -> void:
|
||||
## Defensive: a non-dictionary value from the server must be rejected (null).
|
||||
GameState.apply_snapshot({"tick": 1, "current_monologue": "not-a-dict"})
|
||||
assert_that(GameState.current_monologue).is_null()
|
||||
|
||||
|
||||
func test_gamestate_monologue_duration_seconds_survives_round_trip() -> void:
|
||||
## duration_seconds feeds show_monologue()'s duration param — must not be lost.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "m1", "text": "Test.", "duration_seconds": 7.5},
|
||||
})
|
||||
var dur: float = float(GameState.current_monologue.get("duration_seconds"))
|
||||
assert_that(dur).is_equal_approx(7.5, 0.001)
|
||||
|
||||
|
||||
func test_gamestate_monologue_id_survives_round_trip() -> void:
|
||||
## The id field is used by SimBridge carry-forward deduplication (#477).
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "enter_cargo_bay_001", "text": "Hmm.", "duration_seconds": 3.0},
|
||||
})
|
||||
assert_that(GameState.current_monologue.get("id")).is_equal("enter_cargo_bay_001")
|
||||
|
||||
|
||||
func test_gamestate_monologue_replaced_by_next_snapshot() -> void:
|
||||
## Each snapshot with a monologue replaces the previous value — no accumulation.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_monologue": {"id": "first", "text": "First.", "duration_seconds": 3.0},
|
||||
})
|
||||
GameState.apply_snapshot({
|
||||
"tick": 2,
|
||||
"current_monologue": {"id": "second", "text": "Second.", "duration_seconds": 3.0},
|
||||
})
|
||||
assert_that(GameState.current_monologue.get("id")).is_equal("second")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-049 Z-layer / canvas scope compliance
|
||||
# MonologueDisplay must be parented to UILayer (CanvasLayer, layer=20).
|
||||
# Two tests: (1) constant sanity, (2) scene tree structural verification.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_canvas_ui_constant_is_20() -> void:
|
||||
## D-049: CANVAS_UI = 20 is the agreed HUD layer for monologue display.
|
||||
## Sanity check — constant must not drift from the spec.
|
||||
assert_that(Constants.CANVAS_UI).is_equal(20)
|
||||
|
||||
|
||||
func test_monologue_display_parented_to_canvas_layer_20_in_main_scene() -> void:
|
||||
## D-049: Structural verification — MonologueDisplay must be a direct child of
|
||||
## UILayer (CanvasLayer, layer=20) in the live scene tree, not the world layer.
|
||||
## Catches regressions where the node gets accidentally moved to InsertOverlay
|
||||
## (layer=10) or ModalLayer (layer=30), or dropped into the world z-stack.
|
||||
##
|
||||
## Scene path verified: Game/UILayer/MonologueDisplay (main.tscn line 141).
|
||||
if not ResourceLoader.exists("res://scenes/main.tscn"):
|
||||
push_warning("TestMonologueDisplay: main.tscn not found — D-049 scene tree test skipped")
|
||||
return
|
||||
var scene: Node = load("res://scenes/main.tscn").instantiate()
|
||||
auto_free(scene)
|
||||
add_child(scene)
|
||||
|
||||
var mono: Node = scene.get_node_or_null("UILayer/MonologueDisplay")
|
||||
assert_that(mono != null).is_true()
|
||||
|
||||
var parent: Node = mono.get_parent()
|
||||
assert_that(parent is CanvasLayer).is_true()
|
||||
assert_that((parent as CanvasLayer).layer).is_equal(Constants.CANVAS_UI)
|
||||
+150
-35
@@ -1,48 +1,163 @@
|
||||
extends Control
|
||||
|
||||
# Internal monologue display (per D-015)
|
||||
# Shows character's internal thoughts as text overlay
|
||||
# Internal monologue display — multi-line, priority-queued (per D-016, #122).
|
||||
# Per Tyre architecture review, Sprint 14.
|
||||
#
|
||||
# Up to MAX_VISIBLE lines display simultaneously in a VBoxContainer.
|
||||
# Additional arrivals queue up to MAX_QUEUE depth; lowest-priority entry is
|
||||
# dropped when the queue is full and an equal-or-higher-priority line arrives
|
||||
# (>= tiebreak = FIFO: newest replaces oldest at same priority).
|
||||
#
|
||||
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
|
||||
# Colour: lattice_profile passed in at call time — no autoload access in renderer.
|
||||
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
|
||||
|
||||
@onready var text_panel: PanelContainer = $PanelContainer
|
||||
@onready var text_label: RichTextLabel = $PanelContainer/MarginContainer/RichTextLabel
|
||||
const MAX_VISIBLE: int = 3
|
||||
const MAX_QUEUE: int = 5
|
||||
|
||||
const STAGGER_SEC: float = 0.15
|
||||
const FADE_IN_SEC: float = 0.3
|
||||
const FADE_OUT_SEC: float = 0.5
|
||||
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
|
||||
|
||||
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
|
||||
# standard opacity = 0.85, urgent opacity = 1.0.
|
||||
# Source: Tyre architecture review, Sprint 14.
|
||||
const _LATTICE_COLORS: Dictionary = {
|
||||
"lattice_augmented": { # detective
|
||||
"standard": Color("#d0d4e0"),
|
||||
"urgent": Color("#e0e8f8"),
|
||||
},
|
||||
"lattice_baseline": { # smuggler
|
||||
"standard": Color("#d8d0c4"),
|
||||
"urgent": Color("#f0e4d4"),
|
||||
},
|
||||
}
|
||||
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
|
||||
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
|
||||
|
||||
@onready var _vbox: VBoxContainer = $VBoxContainer
|
||||
|
||||
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
|
||||
var _visible: Array[Dictionary] = []
|
||||
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
|
||||
var _queue: Array[Dictionary] = []
|
||||
# Msec timestamp when the next fade-in may begin (stagger enforcement)
|
||||
var _next_fade_in_msec: float = 0.0
|
||||
|
||||
var fade_timer: float = 0.0
|
||||
var fade_duration: float = 5.0 # Display duration before fade
|
||||
var is_visible: bool = false
|
||||
var _active_tween: Tween = null
|
||||
|
||||
func _ready() -> void:
|
||||
print("MonologueDisplay: Initialized")
|
||||
text_panel.modulate.a = 0.0
|
||||
is_visible = false
|
||||
pass
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Auto-fade after display
|
||||
if is_visible:
|
||||
fade_timer += delta
|
||||
if fade_timer >= fade_duration:
|
||||
_fade_out()
|
||||
# Expire visible lines
|
||||
for slot in _visible.duplicate():
|
||||
slot.expire_timer -= delta
|
||||
if slot.expire_timer <= 0.0:
|
||||
_retire_slot(slot)
|
||||
|
||||
# Show internal monologue text
|
||||
func show_monologue(text: String, duration: float = 5.0) -> void:
|
||||
text_label.text = text
|
||||
fade_duration = duration
|
||||
fade_timer = 0.0
|
||||
is_visible = true
|
||||
# Drain queue into available visible slots (one per stagger interval)
|
||||
if not _queue.is_empty() and _visible.size() < MAX_VISIBLE:
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if now >= _next_fade_in_msec:
|
||||
var next: Dictionary = _queue.pop_front()
|
||||
_show_line(next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile)
|
||||
|
||||
# Cancel any active tween before starting a new one
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(text_panel, "modulate:a", 1.0, 0.3)
|
||||
|
||||
# Fade out the monologue
|
||||
func _fade_out() -> void:
|
||||
if not is_visible:
|
||||
# Display a monologue line.
|
||||
# priority: higher number = more important (default 2; urgent beats normal).
|
||||
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
|
||||
# Empty text is silently ignored — no slot created, no queue entry.
|
||||
# lattice_profile is read from GameState here and passed down — renderer stays
|
||||
# decoupled from the autoload (D-020 renderer contract).
|
||||
func show_monologue(text: String, duration: float, priority: int = 2, is_urgent: bool = false) -> void:
|
||||
if text.is_empty():
|
||||
return
|
||||
var profile := GameState.lattice_profile
|
||||
var now := float(Time.get_ticks_msec())
|
||||
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
|
||||
_show_line(text, duration, priority, is_urgent, profile)
|
||||
else:
|
||||
_enqueue(text, duration, priority, is_urgent, profile)
|
||||
|
||||
is_visible = false
|
||||
if _active_tween and _active_tween.is_valid():
|
||||
_active_tween.kill()
|
||||
_active_tween = create_tween()
|
||||
_active_tween.tween_property(text_panel, "modulate:a", 0.0, 0.5)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _show_line(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
var line_node := _build_line_node(text, is_urgent, lattice_profile)
|
||||
_vbox.add_child(line_node)
|
||||
|
||||
var slot := {
|
||||
node = line_node,
|
||||
expire_timer = maxf(duration, MIN_DURATION), # clamp: survives own fade-in
|
||||
priority = priority,
|
||||
tween = null as Tween,
|
||||
}
|
||||
_visible.append(slot)
|
||||
_next_fade_in_msec = float(Time.get_ticks_msec()) + STAGGER_SEC * 1000.0
|
||||
|
||||
line_node.modulate.a = 0.0
|
||||
var tween := create_tween()
|
||||
slot.tween = tween
|
||||
var target_opacity := 1.0 if is_urgent else 0.85
|
||||
tween.tween_property(line_node, "modulate:a", target_opacity, FADE_IN_SEC)
|
||||
|
||||
|
||||
func _retire_slot(slot: Dictionary) -> void:
|
||||
_visible.erase(slot)
|
||||
var node: Node = slot.node
|
||||
var t: Tween = slot.tween
|
||||
if t and t.is_valid():
|
||||
t.kill()
|
||||
var tween := create_tween()
|
||||
tween.tween_property(node, "modulate:a", 0.0, FADE_OUT_SEC)
|
||||
tween.tween_callback(node.queue_free)
|
||||
|
||||
|
||||
func _enqueue(text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String) -> void:
|
||||
if _queue.size() < MAX_QUEUE:
|
||||
_queue.append({text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile})
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
else:
|
||||
# >= tiebreak: newest replaces oldest at equal priority (FIFO for equal ranks)
|
||||
var lowest := _lowest_priority_idx()
|
||||
if priority >= _queue[lowest].priority:
|
||||
_queue[lowest] = {text = text, duration = duration, priority = priority, is_urgent = is_urgent, lattice_profile = lattice_profile}
|
||||
_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority)
|
||||
# else: incoming line is strictly lower priority — silently drop; no sort needed
|
||||
|
||||
|
||||
func _lowest_priority_idx() -> int:
|
||||
var idx := 0
|
||||
for i in range(1, _queue.size()):
|
||||
if _queue[i].priority < _queue[idx].priority:
|
||||
idx = i
|
||||
return idx
|
||||
|
||||
|
||||
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
|
||||
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
|
||||
var color: Color = palette.get("urgent", _FALLBACK_URGENT) if is_urgent \
|
||||
else palette.get("standard", _FALLBACK_STANDARD)
|
||||
|
||||
var container := MarginContainer.new()
|
||||
container.add_theme_constant_override("margin_left", 4)
|
||||
container.add_theme_constant_override("margin_right", 4)
|
||||
container.add_theme_constant_override("margin_top", 2)
|
||||
container.add_theme_constant_override("margin_bottom", 2)
|
||||
|
||||
var label := RichTextLabel.new()
|
||||
label.bbcode_enabled = true
|
||||
label.fit_content = true
|
||||
label.scroll_active = false
|
||||
label.add_theme_font_size_override("normal_font_size", 13)
|
||||
# Escape [ to prevent BBCode injection from server-sourced text.
|
||||
# [lb] is Godot's BBCode entity for a literal left bracket.
|
||||
var safe_text := text.replace("[", "[lb]")
|
||||
label.text = "[i][color=#%s]%s[/color][/i]" % [color.to_html(false), safe_text]
|
||||
|
||||
container.add_child(label)
|
||||
return container
|
||||
|
||||
@@ -2,37 +2,23 @@
|
||||
|
||||
[ext_resource type="Script" path="res://ui/monologue_display.gd" id="1_monologue"]
|
||||
|
||||
; Monologue display area — bottom-left of viewport.
|
||||
; Anchors: 5% left margin, 50% max width, 25% height from bottom (spec §3.1, z-layer 7).
|
||||
; Lines are created dynamically inside VBoxContainer by monologue_display.gd.
|
||||
[node name="MonologueDisplay" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -150.0
|
||||
grow_horizontal = 2
|
||||
layout_mode = 1
|
||||
anchor_left = 0.05
|
||||
anchor_top = 0.75
|
||||
anchor_right = 0.55
|
||||
anchor_bottom = 0.98
|
||||
grow_horizontal = 1
|
||||
grow_vertical = 0
|
||||
clip_contents = true
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_monologue")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_left = 100.0
|
||||
offset_right = -100.0
|
||||
offset_bottom = 120.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_top = 12
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_bottom = 12
|
||||
|
||||
[node name="RichTextLabel" type="RichTextLabel" parent="PanelContainer/MarginContainer"]
|
||||
layout_mode = 2
|
||||
bbcode_enabled = true
|
||||
text = "Internal monologue will appear here..."
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
anchor_bottom = 1.0
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# Entity Color System — Visual Specification
|
||||
|
||||
**Version:** v0.1 (Sprint 14)
|
||||
**Author:** Araminta (Visual Designer)
|
||||
**Date:** 2026-02-20
|
||||
**Ticket:** #304
|
||||
**Status:** Active — input to client implementation (future sprint), constrains #318 (THE FRIEND visual treatment)
|
||||
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§3), Decision D-033
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Principle
|
||||
|
||||
Entity color encodes the **player character's subjective relationship** to an NPC — not an objective property of the NPC. The same entity can appear as different colors to the detective and the smuggler simultaneously. Color is a **client-side derivation** from the `RelationshipState` field on each `VisibleEntity` in the server's `ObserverSnapshot`. The server never says "this NPC is hostile" — it sends the relationship state the client derives the color from.
|
||||
|
||||
This is asymmetric information rendered visually. It is the most important single system in the game to get right because it is the visual language through which the player reads the world.
|
||||
|
||||
---
|
||||
|
||||
## 2. Relationship → Color Mapping
|
||||
|
||||
### 2.1 Canonical Palette
|
||||
|
||||
| RelationshipState | Color name | Hex | Visual quality | Notes |
|
||||
|-------------------|-----------|-----|----------------|-------|
|
||||
| `Unknown` | Cool teal | `#4a9ebb` | Default for unassessed entities. Cooler than sky, not clinical. | Every new NPC the player hasn't built a view of |
|
||||
| `Known` / `Friendly` | Soft green | `#6bc9a6` | Trusted. Known person. Not safe — just trusted. | People the character knows and has reason to trust |
|
||||
| `PersonOfInterest` | Warm amber | `#e8c547` | Monologue or case file has flagged something. | Not necessarily hostile — flagged |
|
||||
| `Hostile` | Muted red | `#d45d5d` | Player character perceives subjective danger. Not omniscient. | **Red means danger TO YOUR CHARACTER**, not danger in the abstract |
|
||||
|
||||
### 2.2 Non-Entity Colors
|
||||
|
||||
| Entity type | Color name | Hex | Notes |
|
||||
|-------------|-----------|-----|-------|
|
||||
| Static objects | Muted grey | `#8b8ba0` | See §5 for what counts as static |
|
||||
| Player — Detective | Cool blue-white | `#e0e8ff` | Near-neutral. "Self." No relationship loading. |
|
||||
| Player — Smuggler | Warm cream | `#e8e0d0` | Near-neutral. "Self." Slightly warmer than detective. |
|
||||
|
||||
### 2.3 Color Psychology Notes
|
||||
|
||||
The palette was selected for functional warmth (D-043), not conventional danger-coding. Teal (`#4a9ebb`) reads as neutral-curious rather than cold. Green (`#6bc9a6`) reads as familiar rather than "good." Amber (`#e8c547`) reads as noteworthy rather than "warning." Red (`#d45d5d`) is muted — subjective danger, not objective alarm.
|
||||
|
||||
This matters because the detective might see amber where the smuggler sees green for the same NPC. Both are correct. Neither color is lying — they are rendering different epistemic positions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Runtime Derivation
|
||||
|
||||
### 3.1 Data Source
|
||||
|
||||
The server sends each `VisibleEntity` in the `ObserverSnapshot` with a `relationship: RelationshipState` field (`server/src/bridge/types.rs`, `VisibleEntity` struct, line 258). This field is computed per-observer on the server using the observer's knowledge graph — it is NOT a shared NPC property.
|
||||
|
||||
`RelationshipState` variants:
|
||||
- `Unknown` — no assessment, or newly visible
|
||||
- `Known` — character has a relationship but nothing flagged
|
||||
- `PersonOfInterest` — knowledge graph or case file has flagged this entity
|
||||
- `Hostile` — character perceives active danger from this entity
|
||||
|
||||
### 3.2 Client Lookup
|
||||
|
||||
`entity_renderer.gd` maps `RelationshipState` to color via `Constants.color_for_entity_kind()`. The current implementation (`_color_for_kind`, line 181) delegates to this function. The full color lookup should follow:
|
||||
|
||||
```
|
||||
RelationshipState → Color
|
||||
Unknown → #4a9ebb
|
||||
Known / Friendly → #6bc9a6
|
||||
PersonOfInterest → #e8c547
|
||||
Hostile → #d45d5d
|
||||
Static object → #8b8ba0
|
||||
Player entity → #e0e8ff (detective) or #e8e0d0 (smuggler)
|
||||
```
|
||||
|
||||
The player's own entity is identified via `GameState.player_entity_id`. Player color does not participate in the relationship lookup — it is a fixed constant per character selection.
|
||||
|
||||
---
|
||||
|
||||
## 4. Transition Behavior
|
||||
|
||||
### 4.1 Standard Transition
|
||||
|
||||
When an entity's `RelationshipState` changes between snapshots, the color shift is a **0.5-second smooth fade** (linear lerp). This is already implemented in `entity_renderer.gd` via the `_entity_tweens` dictionary and `COLOR_FADE_DURATION = 0.5` constant.
|
||||
|
||||
**Never use an instant color swap.** The visual transition is part of the information delivery — the player reads the relationship changing as a small dramatic moment.
|
||||
|
||||
### 4.2 THE FRIEND's First Shift — Staged Priority
|
||||
|
||||
THE FRIEND's transition from `Known/Friendly` (green `#6bc9a6`) to `PersonOfInterest` (amber `#e8c547`) must be the **first relationship color change** the player observes in the session. The opening 20–25 minutes of gameplay must be staged so no other NPC's relationship state changes before THE FRIEND's shift.
|
||||
|
||||
This requires narrative coordination: the player must have enough time with THE FRIEND as green to internalize what green means. When amber arrives, the player has a reference point. Without that contrast, the color change means nothing.
|
||||
|
||||
See `docs/design/the-friend-visual-treatment.md` (#318) for staging details.
|
||||
|
||||
### 4.3 Edge Cases
|
||||
|
||||
**Entity in fog (unrecognized):** D-033 colors do **not** show through fog for unrecognized entities. An unrecognized entity in fog renders as a neutral grey `#555566` blob with no silhouette features. Once the cognitive delay resolves (D-060, 0.6s base) and recognition completes, the blob transitions to the entity's D-033 color + identifying silhouette feature.
|
||||
|
||||
**Entity in fog (recognized):** If the character recognizes an entity in fog (their knowledge graph identifies them), the insert overlay (z-layer 6) can show a D-033 color glow + faint silhouette feature at ±0.5 tile approximate position. This is insert data, not visual data — the character knows where they were, not where they are.
|
||||
|
||||
**Entity at periphery:** Per D-015 (forward/peripheral/blind vision sectors), entities in the peripheral vision zone are rendered at reduced saturation and alpha. The `modulate.a` value is `Constants.PERIPHERAL_ALPHA` when `visibility == "Peripheral"`. This is already implemented in `entity_renderer.gd`. The D-033 color itself does not change — saturation reduction is handled via the visibility dimming, not a color override.
|
||||
|
||||
**Simultaneous transitions:** Two entities changing relationship state in the same tick each get independent 0.5s tweens. There is no synchronization between entity transitions. This is intentional.
|
||||
|
||||
---
|
||||
|
||||
## 5. Static Objects — Definition and Color
|
||||
|
||||
### 5.1 What is a Static Object
|
||||
|
||||
Static objects are non-person entities that do not have a relationship to the player and are not part of the NPC simulation. They always render at `#8b8ba0` regardless of any game state.
|
||||
|
||||
**Static objects (always `#8b8ba0`):**
|
||||
- Furniture: chairs, tables, desks, counters, crates
|
||||
- Fixtures: terminals, consoles, lockers, shelving
|
||||
- Environmental: doors (in closed state), hatches, panels
|
||||
- Props: datapads, mugs, comms units, cargo containers
|
||||
|
||||
**Not static objects (use D-033 colors):**
|
||||
- All NPCs including Background-tier
|
||||
- Player characters
|
||||
- Carried items (if items become carriable entities, they follow their carrier's relationship color — this is a future sprint consideration, default to static color in v0.1)
|
||||
|
||||
### 5.2 Why Grey, Not Zone Palette
|
||||
|
||||
Static objects use `#8b8ba0` rather than blending into zone floor/wall colors because they need to be visually distinct from the environment while remaining subordinate to entity colors. Grey sits between structure (very low saturation zone palettes) and entities (moderate-to-high saturation D-033 colors) in the saturation hierarchy.
|
||||
|
||||
**Saturation hierarchy (D-044):**
|
||||
|
||||
| Tier | Saturation range | Examples |
|
||||
|------|-----------------|---------|
|
||||
| Entity (D-033) | 40–60% | `#4a9ebb`, `#6bc9a6`, `#e8c547`, `#d45d5d` |
|
||||
| Objects (static + D-052 favorites) | 10–30% | `#8b8ba0`, dusty blue, warm terracotta |
|
||||
| Structure (zone palette) | 5–15% | Zone floor tiles, wall faces |
|
||||
|
||||
Never add an object color that approaches entity saturation levels.
|
||||
|
||||
---
|
||||
|
||||
## 6. Color Blindness Assessment
|
||||
|
||||
### 6.1 Palette Under Common Deficiencies
|
||||
|
||||
The D-033 palette (`#4a9ebb` teal / `#6bc9a6` green / `#e8c547` amber / `#d45d5d` red) relies on hue differentiation as its primary signal. This creates accessibility challenges.
|
||||
|
||||
**Deuteranopia (green-blind, ~5% of males):**
|
||||
The green (`#6bc9a6`) and teal (`#4a9ebb`) may be difficult to distinguish. Both occupy the blue-green range. Under deuteranopia simulation, they compress toward a similar cool-blue appearance. The amber (`#e8c547`) and red (`#d45d5d`) differentiate better — amber reads as yellow, red reads as brownish-orange.
|
||||
|
||||
**Protanopia (red-blind, ~1% of males):**
|
||||
The red (`#d45d5d`) shifts toward olive/brown. More concerning: the red and green (`#6bc9a6`) may become difficult to distinguish. The amber (`#e8c547`) remains clearly distinct.
|
||||
|
||||
**Tritanopia (blue-blind, rare):**
|
||||
The teal (`#4a9ebb`) shifts toward green, potentially conflating Unknown and Known. Less critical as tritanopia is uncommon.
|
||||
|
||||
### 6.2 Risk Assessment
|
||||
|
||||
**High risk:** Deuteranopia — teal/green confusion. A deuteranopic player may not clearly distinguish Unknown from Known/Friendly NPCs.
|
||||
|
||||
**Moderate risk:** Protanopia — red/green confusion. A protanopic player may not clearly distinguish Hostile from Known/Friendly.
|
||||
|
||||
### 6.3 Mitigation Recommendation
|
||||
|
||||
The current palette does not include a secondary differentiation signal beyond hue. For v0.1 (functional placeholder stage with colored rectangles), this is acceptable — the game ships with boxes, not full art, and accessibility features are milestone work.
|
||||
|
||||
**Recommended for v0.1.2+:** Add a shape or pattern secondary signal to entity sprites — border dash pattern or icon badge — that persists independently of hue. E.g., Hostile gets a diamond border, PersonOfInterest gets a cross-hatch border, Unknown gets no border treatment. This would not require changing the D-033 colors (which have been visually designed and approved) but would layer a non-color signal on top.
|
||||
|
||||
**Flag:** This is a known gap to be addressed before the game exits early access. Tracked as future accessibility ticket (not yet created — flag during Sprint 15 planning).
|
||||
|
||||
---
|
||||
|
||||
## 7. Insert Overlay Interaction (D-048)
|
||||
|
||||
In the insert overlay (z-layer 6), entity D-033 colors gain **soft halos**: 2–3px gaussian blur at ~40% blend. This is the insert's interpretation of the relationship data — rendered with organic neural texture.
|
||||
|
||||
In the natural vision layer (z-layer 3), entity colors are rendered with **2px outline in the relationship color** — hard edge, no bloom. This is the player's naked visual perception.
|
||||
|
||||
The difference: the insert annotates, the eye sees. Both use the same color, different rendering treatment.
|
||||
|
||||
When `insert_active == false`, the bloom halos disappear. The underlying hard-edge entity color remains (the character still sees the NPC). Only the insert's annotation layer suppresses.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation Notes for Stig
|
||||
|
||||
The core lookup table is already partially implemented. The complete client-side mapping lives in `client/scripts/constants.gd` in the `color_for_entity_kind()` function. The entity renderer at `client/scripts/rendering/entity_renderer.gd` already:
|
||||
|
||||
- Tracks relationship state per entity in `_entity_relationships`
|
||||
- Detects state changes and starts 0.5s color tweens
|
||||
- Handles peripheral visibility dimming independently
|
||||
|
||||
**Outstanding for future implementation sprint:**
|
||||
1. Ensure `Constants.color_for_entity_kind()` uses all five states above (including the player entity case keyed to `GameState.player_entity_id` and `lattice_profile`).
|
||||
2. Fog-entity rendering (recognized vs unrecognized) is handled by the fog system, not `entity_renderer.gd`. The fog renderer queries the knowledge graph for recognition state.
|
||||
3. Peripheral saturation reduction: currently implemented as alpha reduction (`modulate.a`). This is sufficient for v0.1. True saturation reduction (keeping brightness, reducing colorfulness) would require a shader and is deferred.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Decision Cross-References
|
||||
|
||||
| Decision | Relevance |
|
||||
|----------|-----------|
|
||||
| D-033 | Source of truth for relationship state → color mapping. Hex values in this spec are canonical per D-033 approval. |
|
||||
| D-043 | "Functional warmth" art direction. Palette tone and production principle. |
|
||||
| D-044 | Visual hierarchy (entity > object > structure). Saturation rules. |
|
||||
| D-048 | Neural insert overlay — bloom treatment for D-033 colors on z-layer 6. |
|
||||
| D-049 | Z-level rendering stack. Entities at z-layer 3. Insert at z-layer 6. |
|
||||
| D-059 | Fog shader — recognized vs unrecognized entity treatment in fog. |
|
||||
| D-060 | Cognitive delay — controls when grey fog blob transitions to D-033 color. |
|
||||
|
||||
## Appendix B — Quick Reference for Stig
|
||||
|
||||
| State | Hex | Duration of transition |
|
||||
|-------|-----|----------------------|
|
||||
| Unknown | `#4a9ebb` | 0.5s fade from prior color |
|
||||
| Known/Friendly | `#6bc9a6` | 0.5s fade from prior color |
|
||||
| PersonOfInterest | `#e8c547` | 0.5s fade from prior color |
|
||||
| Hostile | `#d45d5d` | 0.5s fade from prior color |
|
||||
| Static object | `#8b8ba0` | Fixed — no transitions |
|
||||
| Player (detective) | `#e0e8ff` | Fixed — not subject to relationship |
|
||||
| Player (smuggler) | `#e8e0d0` | Fixed — not subject to relationship |
|
||||
| Fog blob (unrecognized) | `#555566` | Transitions to D-033 over 0.3s during 0.6s cognitive delay (D-060) |
|
||||
| Peripheral entities | Any D-033 color at reduced alpha | No separate color |
|
||||
@@ -0,0 +1,252 @@
|
||||
# Environmental Text Visual Standards
|
||||
|
||||
**Version:** v0.1 (Sprint 14)
|
||||
**Author:** Araminta (Visual Designer)
|
||||
**Date:** 2026-02-20
|
||||
**Ticket:** #334
|
||||
**Status:** Active — constrains world-layer text implementation and copy authoring
|
||||
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§5), [Text Display Hierarchy](text-display-hierarchy.md) (#316), Decisions D-036, D-049, D-043
|
||||
|
||||
---
|
||||
|
||||
## 1. What Environmental Text Is (and Isn't)
|
||||
|
||||
Environmental text is text that exists in the physical world of Sova Transit District — on walls, above terminals, scrolling across displays. It is **not HUD**, not dialogue, not monologue. It is part of the same world layer that contains crates, chairs, and NPCs.
|
||||
|
||||
The key distinction: if a sign would survive the neural insert being switched off, it is environmental text. If it requires the insert to display, it belongs in the HUD layers.
|
||||
|
||||
In practice, environmental text renders in the world layer (z-layers 2–4) and is subject to overhead occlusion rules. It is NOT exempt from the fog shader — if a sign is in an unexplored area, the player cannot read it.
|
||||
|
||||
All environmental text renders in **Michroma** — the same typeface as all other game text. The fiction is that all text in the world is mediated through the character's insert perception layer. There is no "world handwriting" typeface separate from the insert-layer font.
|
||||
|
||||
---
|
||||
|
||||
## 2. Signage
|
||||
|
||||
### 2.1 Character Limits
|
||||
|
||||
| Sign type | Max characters | Notes |
|
||||
|-----------|---------------|-------|
|
||||
| Zone identifier (primary) | 20 chars | Facility name, zone designation |
|
||||
| Zone identifier (secondary) | 30 chars | Sublevel, sector, unit designation |
|
||||
| Directional/wayfinding | 15 chars | Single-line, all-caps convention |
|
||||
| Safety/notice | 40 chars | Two-line max, 20 chars per line |
|
||||
| Personal/informal (posted notice) | 60 chars | More casual, can be three lines |
|
||||
|
||||
**Hard limit rationale:** Signs at 64px tile width render at 11px Michroma. At this size, ~10–12 characters fit per 64px. Signs wider than ~3 tiles (192px) become disproportionately large relative to the world. Character limits enforce realistic sign-making.
|
||||
|
||||
### 2.2 Visual Treatment
|
||||
|
||||
| Property | Value | Notes |
|
||||
|----------|-------|-------|
|
||||
| Font | Michroma Regular 400 | No exceptions |
|
||||
| Font size | 11px | At 1080p base |
|
||||
| Z-layer | 2 (wall surface) or 4 (overhead) | Depends on mounting position |
|
||||
| Primary color | `#8899aa` | Concordat Standard signage |
|
||||
| Informal color | `#9aa890` | Krenn vernacular or personal notices |
|
||||
| Opacity | 70% for formal, 65% for informal | World layer — subordinate to entities |
|
||||
| Case | ALL CAPS for formal zone signage | Standard for institutional text |
|
||||
| Case | Mixed case for informal and vernacular | More human, less bureaucratic |
|
||||
|
||||
### 2.3 Mounting Position
|
||||
|
||||
**Wall-mounted (z-layer 2):** Signage on vertical wall surfaces renders as part of the wall face. Y-sorted with the wall, not with entities. The sign appears at approximately 1.5–2 visual tile height (upper third of wall face in the tilt view).
|
||||
|
||||
**Overhead (z-layer 4):** Suspended signs, hanging banners, and ceiling-mounted displays render in the overhead layer. These are subject to the semi-transparent occlusion rule (70% opacity when above entities/player). An NPC walking under a suspended sign becomes partially visible through the sign's opacity.
|
||||
|
||||
**Floor-mounted (z-layer 0–1):** Painted floor markings, directional arrows, hazard lines. These render under everything and are walked over. No occlusion consideration.
|
||||
|
||||
### 2.4 Bilingual Sign Treatment
|
||||
|
||||
When a sign has both Concordat Standard and Krenn vernacular text:
|
||||
- Concordat Standard: primary size (11px), 70% opacity
|
||||
- Krenn vernacular: secondary size (9px), 50% opacity, rendered below the primary line
|
||||
- Both in Michroma. The size + opacity difference signals language register without a separate "handwriting" font.
|
||||
- 2px vertical gap between language lines.
|
||||
|
||||
Only signs explicitly serving both audiences carry both languages. Official Commission signage is Concordat Standard only. The Last Shift menu and informal worker notices are Krenn vernacular only.
|
||||
|
||||
---
|
||||
|
||||
## 3. Terminals
|
||||
|
||||
### 3.1 Two-State Model
|
||||
|
||||
Terminals exist in two visual states depending on player proximity and interaction.
|
||||
|
||||
**State 1 — Ambient (player at range, not interacting):**
|
||||
|
||||
The terminal sprite displays a short ambient identifier: equipment type + designation code. This is always visible within LOS, no interaction required. It is the equivalent of a label on a machine — something a worker in this district would glance at to orient themselves.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Content | 1–2 words + code (e.g., "MANIFEST — TK-07") |
|
||||
| Font | 11px Michroma |
|
||||
| Color | `#8899aa` |
|
||||
| Opacity | 60% |
|
||||
| Z-layer | 4 (overhead, above terminal sprite) |
|
||||
| Visibility range | Within LOS, up to ~4 visual tiles (~256px at 64px/tile) |
|
||||
|
||||
**State 2 — Active (player adjacent, Observe/Interact triggered):**
|
||||
|
||||
When the player triggers Observe on a terminal, the full content becomes readable. This is the only time environmental text content transitions — the ambient identifier expands to full text.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Content | Full terminal display — up to ~12 lines |
|
||||
| Font | 13px Michroma |
|
||||
| Color | `#a8b8c8` |
|
||||
| Opacity | 85% |
|
||||
| Z-layer | 4 |
|
||||
| Max width | 200px floating block |
|
||||
| Text wrapping | Word-wrap within 200px |
|
||||
| Dismissal | Player moves away (WASD) |
|
||||
|
||||
**Transition between states:** No animation. The ambient identifier is replaced by the active block when the Observe verb fires. The opacity increase (60% → 85%) and size increase (11px → 13px) signal the mode change without a sliding panel.
|
||||
|
||||
### 3.2 Terminal Content Format
|
||||
|
||||
Terminal text is formatted as structured data — this is a computer display, not prose.
|
||||
|
||||
```
|
||||
[HEADER LINE IN ALL CAPS]
|
||||
Field: Value
|
||||
Field: Value
|
||||
---
|
||||
Additional section
|
||||
```
|
||||
|
||||
The Michroma font at 13px with `#a8b8c8` at 85% renders this structure clearly. Colon-separated fields mimic insert data formatting and reinforce the diegetic conceit.
|
||||
|
||||
**Character limits per line:** ~25 characters at 200px max width, 13px Michroma. Content authors should target 20 characters per line for comfortable reading.
|
||||
|
||||
### 3.3 Terminal Icon (Insert Layer)
|
||||
|
||||
When the player's insert is active and a terminal is within interaction range (~2 visual tiles), the insert may overlay a small geometric icon on the terminal — a 4px cross or bracket indicating "this is interactive." This is insert data (z-layer 6), not environmental text (z-layer 4). It renders regardless of whether the player is looking at the terminal.
|
||||
|
||||
The insert icon uses the standard insert chrome color (`#c8d0e0` at 80% opacity). If the terminal has flagged data (e.g., manifest discrepancy), the icon shifts to amber `#e8c547`.
|
||||
|
||||
---
|
||||
|
||||
## 4. News Tickers
|
||||
|
||||
### 4.1 Overview
|
||||
|
||||
News tickers are ambient scrolling text elements attached to specific display fixtures in the scene. They are world-layer content — not HUD overlays. The player must physically approach and observe them.
|
||||
|
||||
News tickers serve two functions:
|
||||
1. **World texture:** Makes the station feel like a living, connected place (Commission bulletins, interstellar news)
|
||||
2. **Incidental information:** Occasionally carries plot-relevant data the player can discover by paying attention
|
||||
|
||||
The player is never required to read a news ticker to progress. When a ticker carries relevant information, the character's monologue will comment on it (via `observe_anomaly` or `enter_location` trigger) — the player doesn't have to stare at a scrolling display.
|
||||
|
||||
### 4.2 Visual Treatment
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Z-layer | 4 |
|
||||
| Font | 10px Michroma |
|
||||
| Color | `#8899aa` |
|
||||
| Opacity | 55% |
|
||||
| Language | Concordat Standard |
|
||||
| Scroll direction | Right-to-left |
|
||||
| Scroll speed | 30px/second |
|
||||
| Loop | Yes — repeating |
|
||||
| Pause on proximity | Yes — when player within 2 visual tiles, scroll pauses |
|
||||
|
||||
**Why 55% opacity:** Tickers are the most ambient environmental text element. They must not draw attention during active gameplay. A player engaged with NPCs should not be distracted by scrolling text in their peripheral vision. 55% opacity makes them effectively invisible until the player deliberately turns toward them.
|
||||
|
||||
**Pause on proximity:** When the player stands within 2 visual tiles of a ticker display, the scroll pauses to allow reading. This rewards deliberate attention without requiring the player to chase scrolling text.
|
||||
|
||||
### 4.3 Content Length
|
||||
|
||||
News ticker content is authored in segments:
|
||||
- **Segment:** A complete sentence or news item, max 80 characters
|
||||
- **Gap:** A `————` separator between segments (3–4 em-dashes, full width)
|
||||
- **Loop depth:** 3–5 segments per ticker. More segments means longer before repeating — less metagameable.
|
||||
|
||||
Segments scroll sequentially separated by the gap. The player who watches a full loop has seen all content for that ticker.
|
||||
|
||||
### 4.4 Fixture Attachment
|
||||
|
||||
Each news ticker is attached to a specific fixture sprite (terminal, display panel, wall screen). The ticker text renders in a `Rect` overlaid on the upper portion of the fixture sprite. The fixture artist marks the text bounds when designing the fixture.
|
||||
|
||||
For v0.1 (colored rectangle stage), the ticker is a floating text element positioned above the fixture's placeholder rectangle. When sprite art arrives, the artist provides the correct text bounds.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rendering Rules Summary
|
||||
|
||||
### 5.1 Z-Layer Assignment
|
||||
|
||||
| Text sub-type | Z-layer | Occlusion? | Fog-affected? |
|
||||
|---------------|---------|------------|---------------|
|
||||
| Floor signage | 0–1 | No (under entities) | Yes |
|
||||
| Wall signage | 2 | Via y-sort | Yes |
|
||||
| Terminal ambient ID | 4 | Semi-transparent occlusion | Yes |
|
||||
| Terminal active display | 4 | Semi-transparent occlusion | Yes |
|
||||
| News ticker | 4 | Semi-transparent occlusion | Yes |
|
||||
| Insert terminal icon | 6 | No (insert layer) | No |
|
||||
|
||||
**Fog-affected:** Yes means the text is invisible in unexplored areas. The player cannot read a sign they haven't walked past. This is correct and intentional.
|
||||
|
||||
### 5.2 Entity Occlusion Rule
|
||||
|
||||
Environmental text on z-layer 4 (overhead) renders at **70% opacity maximum** — the same rule as all overhead layer content. An entity moving below an overhead sign remains partially visible through the sign. This is the "local information gap within a known space" design (D-049 §4.4).
|
||||
|
||||
Environmental text on z-layer 2 (wall surface) is y-sorted with entities. If an entity is standing in front of a wall sign, the sign is occluded by the entity. This is correct — the entity is always the primary visual element (D-044).
|
||||
|
||||
### 5.3 Text Never Occludes Entities
|
||||
|
||||
Entities are always visually dominant (D-044: entity > object > structure). Environmental text never achieves enough opacity to obscure entity colors. If a layout choice places text where it would overlap an entity, the text loses — reduce opacity or reposition. The entity's D-033 color must remain readable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Language by Context — Decision Matrix
|
||||
|
||||
| Location | Formal element | Informal element |
|
||||
|----------|---------------|-----------------|
|
||||
| The Terminal (logistics hub) | Concordat Standard | Krenn vernacular where workers have added personal notices |
|
||||
| The Last Shift (bar) | Concordat Standard (Commission notices, if any) | Krenn vernacular (menu, worker notices, personal signs) |
|
||||
| Maintenance corridors | Concordat Standard (safety markings) | Krenn vernacular (informal worker notes) |
|
||||
| Cargo containers | Concordat Standard (manifests, labels) | None |
|
||||
| Personal effects area | Neither — no formal signage | Krenn vernacular if labeled |
|
||||
|
||||
**Decision rule:** Formal = the institution did it. Informal = a person did it. The institution writes in Concordat Standard. People write in Krenn vernacular. When you're not sure, ask who made the sign.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Decision Cross-References
|
||||
|
||||
| Decision | Relevance |
|
||||
|----------|-----------|
|
||||
| D-036 | Sova Transit District / Krenn System. Concordat Standard vs Krenn vernacular framework. |
|
||||
| D-043 | "Functional warmth" art direction. Environmental text is world texture, not decoration. |
|
||||
| D-044 | Visual hierarchy. Environmental text is subordinate to entities at all times. |
|
||||
| D-049 | Z-level rendering stack. Layer assignments for all text types. |
|
||||
| D-051 | Settling is placement. Environmental text density reflects the district's history. |
|
||||
| D-066 | Dual-scale grid. 64px tiles → ~10–12 characters per tile at 11px Michroma. |
|
||||
|
||||
## Appendix B — Quick Reference for Copy Team
|
||||
|
||||
| Format | Max chars/line | Lines | Language |
|
||||
|--------|---------------|-------|----------|
|
||||
| Zone signage (primary) | 20 | 1 | Concordat Standard |
|
||||
| Zone signage (secondary) | 30 | 1 | Concordat Standard |
|
||||
| Directional | 15 | 1, ALL CAPS | Concordat Standard |
|
||||
| Safety notice | 20 | 2 | Concordat Standard |
|
||||
| Informal posted notice | 20 | 3 | Krenn vernacular |
|
||||
| Terminal active (per line) | 25 | Up to 12 | Either, per context |
|
||||
| News ticker (per segment) | 80 | 1 | Concordat Standard |
|
||||
|
||||
## Appendix C — Quick Reference for Stig
|
||||
|
||||
| Sub-type | Z-layer | Font | Size | Hex | Opacity |
|
||||
|----------|---------|------|------|-----|---------|
|
||||
| Formal signage | 2 or 4 | Michroma | 11px | `#8899aa` | 70% |
|
||||
| Informal signage | 2 or 4 | Michroma | 11px | `#9aa890` | 65% |
|
||||
| Terminal ambient | 4 | Michroma | 11px | `#8899aa` | 60% |
|
||||
| Terminal active | 4 | Michroma | 13px | `#a8b8c8` | 85% |
|
||||
| News ticker | 4 | Michroma | 10px | `#8899aa` | 55% |
|
||||
| Insert terminal icon (active) | 6 | — | 4px glyph | `#c8d0e0` | 80% |
|
||||
| Insert terminal icon (flagged) | 6 | — | 4px glyph | `#e8c547` | 80% |
|
||||
@@ -0,0 +1,276 @@
|
||||
# Monologue Display System — Visual Specification
|
||||
|
||||
**Version:** v0.1 (Sprint 13, updated Sprint 14)
|
||||
**Author:** Araminta (Visual Designer)
|
||||
**Date:** 2026-02-19
|
||||
**Ticket:** #315
|
||||
**Status:** Active — constrains copy authoring (#299, #300) and future client monologue UI implementation
|
||||
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§5.2, §5.3)
|
||||
|
||||
---
|
||||
|
||||
## 1. Typography
|
||||
|
||||
### 1.1 Typeface
|
||||
|
||||
**Michroma** (Google Fonts, Regular 400) — no exceptions. Monologue renders through the same neural insert perception layer as all other text (Visual Grammar §5.4). There is no separate "inner voice font."
|
||||
|
||||
### 1.2 Size and Weight
|
||||
|
||||
All sizes at 1080p base resolution. Godot 4 handles DPI scaling.
|
||||
|
||||
| Variant | Size | Weight | Notes |
|
||||
|---------|------|--------|-------|
|
||||
| Standard | 13px | Regular 400 | Interior voice. Quieter than dialogue (16px). |
|
||||
| Urgent | 13px | Regular 400 | Same size — urgency is communicated through opacity, color, and bloom, not typographic scale. |
|
||||
|
||||
**No bold, no italic, no size variation within a single line.** Monologue is the character's unformatted thought stream. Emphasis comes from the writing, not from styling.
|
||||
|
||||
### 1.3 Character-Differentiated Color
|
||||
|
||||
Monologue text color reflects the character's register. The detective thinks in cool tones (analytical, institutional). The smuggler thinks in warm tones (social, street-level). These colors are deliberately desaturated — monologue should never compete with D-033 entity colors for visual attention.
|
||||
|
||||
**Detective:**
|
||||
|
||||
| Variant | Color | Hex | Opacity |
|
||||
|---------|-------|-----|---------|
|
||||
| Standard | Cool grey-blue | `#d0d4e0` | 85% |
|
||||
| Urgent | Bright cool blue | `#e0e8f8` | 100% |
|
||||
|
||||
**Smuggler:**
|
||||
|
||||
| Variant | Color | Hex | Opacity |
|
||||
|---------|-------|-----|---------|
|
||||
| Standard | Warm grey-cream | `#d8d0c4` | 85% |
|
||||
| Urgent | Bright warm cream | `#f0e4d4` | 100% |
|
||||
|
||||
### 1.4 Urgent Bloom
|
||||
|
||||
Urgent monologue lines receive a subtle bloom pulse: 2px gaussian blur at 30% blend, pulsing between 20% and 40% over a 1.5-second cycle. The pulse begins on fade-in and continues through the hold duration. This is the only visual animation on monologue text.
|
||||
|
||||
The urgent recognition chime (D-067, UISounds bus) fires simultaneously with the bloom onset.
|
||||
|
||||
---
|
||||
|
||||
## 2. Positioning
|
||||
|
||||
### 2.1 Screen Anchor
|
||||
|
||||
Monologue occupies a **fixed screen position** — not entity-relative. The character's inner voice is not a speech bubble; it exists in the player's perceptual space, not the game world's physical space.
|
||||
|
||||
### 2.2 Placement
|
||||
|
||||
Monologue lines are anchored to the **lower-left quadrant** of the screen, above the dialogue box region.
|
||||
|
||||
| Property | Value | Notes |
|
||||
|----------|-------|-------|
|
||||
| Horizontal anchor | Left edge + 5% screen width margin | Left-aligned, clear of screen edge |
|
||||
| Vertical anchor | 25% from screen bottom | Above dialogue box max height (20%). 5% gap separates monologue from dialogue top edge. |
|
||||
| Max text width | 50% screen width | Prevents lines from spanning the full screen. At 1920px, this is 960px — comfortably fits 80 characters at 13px Michroma. |
|
||||
|
||||
### 2.3 Dialogue Box Relationship
|
||||
|
||||
The dialogue box (D-061) occupies the bottom 20% of the screen. Monologue sits directly above it with a 5% gap. This spatial separation is the core mechanic: the character thinks one thing (above, inner) while the NPC speaks another (below, outer).
|
||||
|
||||
When dialogue is **inactive**, monologue lines remain at the same vertical position — as if the dialogue box were present but invisible. The monologue region does not slide down to fill the gap. Consistent positioning trains the player to associate the lower-left quadrant with interior thought.
|
||||
|
||||
### 2.4 Z-Layer
|
||||
|
||||
**Z-layer 7** (D-049). Topmost rendering layer. Monologue is:
|
||||
- Never occluded by game world elements (layers 0–4)
|
||||
- Never affected by fog shader (layer 5)
|
||||
- Rendered above the insert overlay (layer 6)
|
||||
- Co-resident with dialogue box and HUD chrome on layer 7
|
||||
|
||||
Within layer 7, monologue text renders above the dialogue box but below modal UI (world menu, pause).
|
||||
|
||||
---
|
||||
|
||||
## 3. Stacking Rules
|
||||
|
||||
### 3.1 Maximum Visible Lines
|
||||
|
||||
**3 lines maximum.** This is a hard cap. If a 4th line arrives while 3 are displayed, the priority system (§4) determines which line yields.
|
||||
|
||||
### 3.2 Stack Direction
|
||||
|
||||
**Bottom-up.** The newest line appears at the bottom of the monologue region (closest to the dialogue box). Older lines shift upward. This keeps the freshest thought spatially adjacent to the dialogue it may contradict.
|
||||
|
||||
```
|
||||
[oldest line — fading out] ← shifts up, fading
|
||||
[middle line] ← shifts up
|
||||
[newest line — fading in] ← appears here
|
||||
─────────────────────────────────
|
||||
[dialogue box] ← bottom 20% of screen
|
||||
```
|
||||
|
||||
### 3.3 Shift Animation
|
||||
|
||||
When a new line enters and pushes older lines upward, the vertical shift takes **0.2 seconds** (ease-out). Lines do not teleport to their new position.
|
||||
|
||||
### 3.4 Line Spacing
|
||||
|
||||
**4px vertical gap** between stacked lines. At 13px font size, this gives 17px per line slot — compact but legible.
|
||||
|
||||
---
|
||||
|
||||
## 4. Priority System
|
||||
|
||||
### 4.1 Priority Tiers
|
||||
|
||||
Monologue lines belong to one of three tiers, in descending priority:
|
||||
|
||||
| Priority | Tier | Examples |
|
||||
|----------|------|---------|
|
||||
| 1 (highest) | **Observation** | Entity recognition, anomaly detection, item discovery, environmental deduction |
|
||||
| 2 | **Atmosphere** | Zone commentary, ambient thought, character reflection, idle musing |
|
||||
| 3 (lowest) | **Tutorial** | System hints, control reminders, first-time explanations |
|
||||
|
||||
### 4.2 Overflow Behavior
|
||||
|
||||
When a new line arrives and the display is at capacity (3 lines):
|
||||
|
||||
1. **If the new line's priority is higher than the lowest-priority displayed line:** The lowest-priority displayed line immediately begins its fade-out (compressed to 0.3s instead of normal 1.0s). The new line enters at the bottom after the evicted line clears.
|
||||
|
||||
2. **If the new line's priority is equal to or lower than all displayed lines:** The new line is **deferred** — queued and displayed when a slot opens naturally. Maximum queue depth: 5 lines. If the queue is full, the lowest-priority queued line is silently dropped.
|
||||
|
||||
3. **Same-priority collision:** Within the same tier, newer lines take precedence over older ones. An observation evicts an older observation before it evicts an atmosphere line.
|
||||
|
||||
### 4.3 Urgent Override
|
||||
|
||||
Urgent monologue lines (observation tier only) **always display immediately**, evicting the lowest-priority visible line regardless of tier match. If all 3 visible lines are observation-tier, the oldest observation line is evicted.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fade Animation
|
||||
|
||||
### 5.1 Timing
|
||||
|
||||
Each line fades **independently** — lines do not synchronize their lifecycles.
|
||||
|
||||
| Phase | Duration | Easing |
|
||||
|-------|----------|--------|
|
||||
| Fade-in | 0.3s | Ease-out (quick arrival, not instant) |
|
||||
| Hold | 4.0s | — |
|
||||
| Fade-out | 1.0s | Ease-in (slow, contemplative departure) |
|
||||
|
||||
**Total visible duration:** 5.3 seconds per line.
|
||||
|
||||
### 5.2 Opacity Curve
|
||||
|
||||
- **Fade-in:** 0% → target opacity (85% standard, 100% urgent) over 0.3s
|
||||
- **Hold:** Target opacity sustained for 4.0s
|
||||
- **Fade-out:** Target opacity → 0% over 1.0s
|
||||
|
||||
### 5.3 Eviction Fade
|
||||
|
||||
When a line is evicted by a higher-priority line (§4.2), its fade-out is compressed from 1.0s to **0.3s**. This fast exit makes room for urgent content without feeling jarring.
|
||||
|
||||
### 5.4 Rapid Succession
|
||||
|
||||
If multiple monologue lines fire within 0.5 seconds of each other (e.g., multi-part observation), each line staggers by **0.15 seconds**. The second line begins its fade-in 0.15s after the first, the third 0.15s after the second. This prevents a wall of text appearing simultaneously while keeping the burst feeling connected.
|
||||
|
||||
---
|
||||
|
||||
## 6. Character Differentiation
|
||||
|
||||
### 6.1 Design Principle
|
||||
|
||||
The smuggler and detective occupy the same world, see the same events, but experience them through different cognitive registers. Monologue is where this divergence is most visible. Per D-043 ("functional warmth"), the smuggler's interior world is warmer — social awareness, practical assessment. The detective's is cooler — analytical distance, procedural framing.
|
||||
|
||||
This is expressed through **color temperature only**. Size, position, stacking, animation, and font are identical between characters. The difference is subtle and accumulative — not a jarring mode switch.
|
||||
|
||||
### 6.2 Color Summary
|
||||
|
||||
| Character | Standard | Urgent | Register |
|
||||
|-----------|----------|--------|----------|
|
||||
| Detective | `#d0d4e0` at 85% | `#e0e8f8` at 100% | Cool blue-grey. Institutional. Observational. |
|
||||
| Smuggler | `#d8d0c4` at 85% | `#f0e4d4` at 100% | Warm cream-grey. Social. Practical. |
|
||||
|
||||
### 6.3 Implementation Note
|
||||
|
||||
The active character's `lattice_profile` (from `ObserverSnapshot`) determines which color set is used. `lattice_augmented` (detective) → cool palette. `lattice_baseline` (smuggler) → warm palette. No runtime switch within a session — character is selected at game start.
|
||||
|
||||
---
|
||||
|
||||
## 7. Line Length Constraint
|
||||
|
||||
### 7.1 Hard Limit
|
||||
|
||||
**80 characters per line, maximum.** This is a display constraint, not a guideline.
|
||||
|
||||
At 13px Michroma on a 1080p display with 50% max text width (960px), 80 characters fits comfortably with margin. Lines exceeding 80 characters will be truncated at the last word boundary before the limit — no mid-word breaks.
|
||||
|
||||
### 7.2 Copy Team Contract
|
||||
|
||||
All monologue content authored for The Settled Reach must respect this constraint:
|
||||
|
||||
- **Maximum:** 80 characters per line (including spaces and punctuation)
|
||||
- **Target:** 40–65 characters per line (comfortable reading rhythm)
|
||||
- **Minimum:** No minimum, but lines under 20 characters should be rare — they waste visual space
|
||||
|
||||
Multi-line monologue thoughts (e.g., a two-part observation) should be authored as separate lines, each respecting the 80-character limit, delivered via the rapid succession stagger (§5.4).
|
||||
|
||||
### 7.3 Validation
|
||||
|
||||
Content tooling should flag lines exceeding 80 characters at authoring time, not at runtime. Runtime truncation is a fallback, not a workflow.
|
||||
|
||||
---
|
||||
|
||||
## 8. D-033 Cross-Reference — Color Clash Analysis
|
||||
|
||||
### 8.1 Entity Relationship Colors
|
||||
|
||||
| State | Hex | Saturation | Lightness |
|
||||
|-------|-----|-----------|-----------|
|
||||
| Unknown/Neutral | `#4a9ebb` | ~45% | ~52% |
|
||||
| Known/Friendly | `#6bc9a6` | ~40% | ~60% |
|
||||
| Person of Interest | `#e8c547` | ~75% | ~59% |
|
||||
| Hostile/Dangerous | `#d45d5d` | ~55% | ~60% |
|
||||
| Static objects | `#8b8ba0` | ~10% | ~58% |
|
||||
|
||||
### 8.2 Monologue Text Colors
|
||||
|
||||
| Variant | Hex | Saturation | Lightness |
|
||||
|---------|-----|-----------|-----------|
|
||||
| Detective standard | `#d0d4e0` | ~15% | ~85% |
|
||||
| Detective urgent | `#e0e8f8` | ~38% | ~92% |
|
||||
| Smuggler standard | `#d8d0c4` | ~12% | ~81% |
|
||||
| Smuggler urgent | `#f0e4d4` | ~30% | ~89% |
|
||||
|
||||
### 8.3 Clash Assessment
|
||||
|
||||
**No clashes.** Monologue text occupies the high-lightness, low-saturation range (L: 81–92%, S: 12–38%). Entity colors occupy the moderate-lightness, moderate-to-high saturation range (L: 52–60%, S: 10–75%). These color spaces do not overlap.
|
||||
|
||||
The closest potential overlap is between static object grey (`#8b8ba0`, low saturation) and monologue text — but the lightness gap (58% vs 81%+) maintains clear separation, and monologue text on z-layer 7 never spatially overlaps with entity sprites on z-layer 3.
|
||||
|
||||
### 8.4 Warm Amber Adjacency
|
||||
|
||||
The smuggler's urgent text (`#f0e4d4`) and the Person of Interest amber (`#e8c547`) share a warm register. This is thematically appropriate — the smuggler's heightened awareness (urgent monologue) coincides with the visual system flagging someone as noteworthy. They are not visually confusable: the text is desaturated near-white cream, while amber is a saturated gold-yellow at much lower lightness.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Decision Cross-References
|
||||
|
||||
| Decision | Relevance to this spec |
|
||||
|----------|----------------------|
|
||||
| D-016 | Monologue as core perception/atmosphere system. Defines function and character variance. |
|
||||
| D-033 | Entity relationship colors. Cross-referenced in §8 for clash avoidance. |
|
||||
| D-043 | "Functional warmth" art direction. Grounds character color differentiation (§6). |
|
||||
| D-044 | Visual hierarchy (entity > object > structure). Monologue must not compete. |
|
||||
| D-049 | Z-level rendering stack. Monologue is z-layer 7 (§2.4). |
|
||||
| D-061 | Dialogue box layout. Monologue spatial relationship defined in §2.3. |
|
||||
| D-066 | Dual-scale grid. Positioning uses screen-relative units, not tile units. |
|
||||
| D-067 | Recognition chime fires at cognitive delay onset, coinciding with urgent monologue bloom. |
|
||||
|
||||
## Appendix B — Quick Reference for Copy Team
|
||||
|
||||
| Constraint | Value |
|
||||
|-----------|-------|
|
||||
| Max characters per line | **80** (hard limit) |
|
||||
| Target characters per line | 40–65 (comfortable) |
|
||||
| Max visible lines | 3 |
|
||||
| Line hold duration | 4.0 seconds |
|
||||
| Total visible time | 5.3 seconds |
|
||||
| Multi-line stagger | 0.15s between lines |
|
||||
| Priority: observation > atmosphere > tutorial | Observation always displays; tutorial may be deferred |
|
||||
@@ -0,0 +1,228 @@
|
||||
# Sound Indicator Visual Design — Fog-Edge Pulse Specification
|
||||
|
||||
**Version:** v0.1 (Sprint 14)
|
||||
**Author:** Araminta (Visual Designer)
|
||||
**Date:** 2026-02-20
|
||||
**Ticket:** #317
|
||||
**Status:** Active — constrains client sound_indicator_renderer.gd implementation
|
||||
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md), Decision D-018 (three-range sound model)
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose and Scope
|
||||
|
||||
Sound indicators are **complementary to audio, not a replacement for it.** They exist for players with audio off, in loud environments, or when audio is present but directionality is ambiguous. They should not be noticeable during routine exploration — only when something specific warrants attention.
|
||||
|
||||
The v0.1 implementation uses **directional arrows at the viewport edge** as the indicator shape (already implemented in `sound_indicator_renderer.gd`). This spec defines the full intended visual design including the pulse behavior described in the ticket, and notes where the current implementation differs from the spec.
|
||||
|
||||
**D-018 scope:** Sound indicators cover the **medium-range tier** (sounds outside LOS, within ~3–20 tiles). Close-range sounds (≤3 tiles, within LOS) are handled by positional 2D audio — no indicator needed. Within medium range, three sub-tiers (close-medium, standard, far-medium) differentiate visual intensity — see §7 for the full breakdown.
|
||||
|
||||
---
|
||||
|
||||
## 2. Colors
|
||||
|
||||
Per D-018 three-range sound model and D-033/D-048 color vocabulary:
|
||||
|
||||
| Sound category | Hex | Usage |
|
||||
|----------------|-----|-------|
|
||||
| Neutral | `#c8d0e0` | Footsteps, ambient movement, cargo handling, non-social activity |
|
||||
| Voice | `#e8c547` | NPC conversation, speech, social activity |
|
||||
| Danger | `#d45d5d` | Alarms, alerts, gunshots, explosions, threats |
|
||||
|
||||
These are the same as the insert chrome color (`#c8d0e0`), the Person of Interest amber (`#e8c547`), and the Hostile red (`#d45d5d`). The alignment is intentional — the sound indicator system is part of the insert overlay, and its color vocabulary maps directly to the entity relationship system. A voice indicator uses the same amber as a Person of Interest entity because *voices are people, and people are potentially interesting.*
|
||||
|
||||
---
|
||||
|
||||
## 3. Position — Fog Edge, Not Screen Edge
|
||||
|
||||
### 3.1 Principle
|
||||
|
||||
Sound indicators live at the **boundary of the player's visible cone** — where clear vision meets fog. Not at the physical screen border, not as a minimap overlay.
|
||||
|
||||
The current implementation projects indicators to the **viewport boundary** (approximately correct — viewport edge ≈ fog edge at the camera's field of view). This is sufficient for v0.1. The spec's intent is that indicators should feel like they're at the perceptual boundary, not tacked to the UI chrome.
|
||||
|
||||
`EDGE_INSET = 20.0px` in the current implementation provides the correct "just inside the boundary" feel.
|
||||
|
||||
### 3.2 Z-Layer
|
||||
|
||||
Sound indicators render on **z-layer 6** (insert overlay), not z-layer 5 (fog). They are insert data — the character's lattice is processing the sound, not the character's naked ears. They are unaffected by the fog shader.
|
||||
|
||||
The current implementation (`sound_indicator_renderer.gd`) renders as a `Node2D` draw call. This should be confirmed as rendering on z-layer 6 in the scene tree.
|
||||
|
||||
---
|
||||
|
||||
## 4. Shape — Arrow vs Pulse Arc
|
||||
|
||||
### 4.1 Current Implementation
|
||||
|
||||
The current `sound_indicator_renderer.gd` renders filled arrowhead triangles:
|
||||
- Tip at the viewport boundary edge point
|
||||
- Arrow points toward the sound source
|
||||
- Size: 12px length, 7px half-width
|
||||
- Filled polygon, not an outline
|
||||
|
||||
This is a valid v0.1 placeholder. It communicates direction clearly.
|
||||
|
||||
### 4.2 Target Design — Pulse Arc Segment
|
||||
|
||||
The intended final design is a **thin arc segment at the fog boundary** rather than a solid arrow. The arc reads as "sound reaching the edge of perception" rather than "here is an arrow pointing at something."
|
||||
|
||||
| Property | Value | Notes |
|
||||
|----------|-------|-------|
|
||||
| Shape | Arc segment (partial ring) | Centered on source direction, 40° sweep |
|
||||
| Arc radius | 6px — visually thin | Not a thick ring |
|
||||
| Position | Fog boundary | Radiates outward from boundary inward by 3px |
|
||||
| Animation | Pulse outward and fade | Single pulse per event, see §5 |
|
||||
|
||||
**Arc sweep:** 40° centered on the direction to the sound source. Narrow enough to clearly point, wide enough to be visible at a glance. A line would be too thin; a semicircle too vague.
|
||||
|
||||
**For v0.1:** Continue using the current arrow implementation. The arc design is the target for when sprite art replaces colored rectangles — the arc will feel more natural against full art than a solid arrowhead.
|
||||
|
||||
---
|
||||
|
||||
## 5. Animation — Pulse Behavior
|
||||
|
||||
### 5.1 Current Implementation
|
||||
|
||||
The current implementation renders a static indicator (no pulse animation) that lives for `INDICATOR_LIFETIME = 3.5s` and fades over the last `FADE_DURATION = 0.6s`. The fade is a linear alpha ramp from full opacity to 0.
|
||||
|
||||
This is functional but does not communicate the sound event as a *moment* — it reads as a persistent marker rather than an alert.
|
||||
|
||||
### 5.2 Target Pulse Specification
|
||||
|
||||
The intended animation is a **single expanding pulse per sound event**, not a persistent marker:
|
||||
|
||||
| Phase | Duration | Behavior |
|
||||
|-------|----------|----------|
|
||||
| Onset | 0.0–0.2s | Indicator appears at full opacity, max brightness |
|
||||
| Expand | 0.2–0.6s | Indicator expands outward by 3–4px (for arrow: scale 1.0 → 1.3) |
|
||||
| Hold | 0.6–1.5s | Full opacity, static size |
|
||||
| Fade | 1.5–2.5s | Linear alpha 100% → 0% |
|
||||
|
||||
**Total duration:** 2.5s (vs current 3.5s). The shorter duration prevents indicators from lingering as persistent clutter.
|
||||
|
||||
**For v0.1:** Implement the onset flash (full opacity on appear) and the fade. Skip the expand animation if performance is constrained — the expand is polish.
|
||||
|
||||
**Easing:** Onset is instant (no fade-in — sound events are sudden, their indicators should be too). Fade-out uses ease-in (slow start, accelerates to transparent). The sound indicator should feel like it vanishes rather than slowly becoming invisible.
|
||||
|
||||
### 5.3 Deduplication
|
||||
|
||||
Same-source deduplication is already implemented correctly: if an indicator exists at a tile position, new events reset its timer rather than stacking. This prevents a continuous conversation from spawning dozens of overlapping indicators.
|
||||
|
||||
---
|
||||
|
||||
## 6. Direction Encoding
|
||||
|
||||
### 6.1 Arrow Direction
|
||||
|
||||
The current implementation projects the indicator to the viewport boundary in the direction of the sound source from the player. The arrowhead tip points toward the sound source. This correctly encodes direction.
|
||||
|
||||
**Rule:** The indicator tip always points toward the source, not away from it. The player reads "the sound is in that direction."
|
||||
|
||||
### 6.2 Arc Direction (Target Design)
|
||||
|
||||
In the arc design, the arc is centered on the direction vector from player to sound source. The arc's midpoint lies on the line from player to source, at the fog boundary. The 40° sweep is centered on this midpoint. The arc opens toward the source (the open side of the arc faces the player, the midpoint faces the source).
|
||||
|
||||
---
|
||||
|
||||
## 7. Range Differentiation — Three Visual Levels
|
||||
|
||||
D-018 specifies three distance ranges. Sound indicators apply to medium range (outside LOS), but within medium there are visual levels based on proximity:
|
||||
|
||||
| Distance | Visual treatment | Alpha | Notes |
|
||||
|----------|-----------------|-------|-------|
|
||||
| Close-medium (3–5 tiles) | Full indicator, 90% alpha | 90% | Clear, noticeable |
|
||||
| Standard medium (5–12 tiles) | Standard indicator, 70% alpha | 70% | Visible but not urgent |
|
||||
| Far-medium (12–20 tiles) | Smaller indicator, 45% alpha | 45% | Subtle, ambient |
|
||||
|
||||
**For the arrow implementation:** Scale the arrow by distance proxy — close-medium at `scale(1.0)`, far-medium at `scale(0.7)`. The size reduction plus alpha reduction creates a clear near-vs-far reading.
|
||||
|
||||
**Alpha cap at 90%:** Sound indicators should never be fully opaque. They are insert data, not a HUD alert. The 10% transparency gap maintains their insert-layer quality.
|
||||
|
||||
---
|
||||
|
||||
## 8. Suppression Rules — When Indicators Do NOT Appear
|
||||
|
||||
Sound indicators are suppressed in the following conditions:
|
||||
|
||||
| Condition | Rule |
|
||||
|-----------|------|
|
||||
| Sound source within player's LOS | Suppressed. The player can see/hear the source directly. |
|
||||
| Sound source at close range (≤3 tiles) | Suppressed. Close range is handled by positional 2D audio. |
|
||||
| Sound source outside 20-tile radius | Suppressed. Long-range sounds are insert notification territory (future sprint). |
|
||||
| `insert_active == false` | Suppressed. Sound indicators are insert overlay elements (z-layer 6). |
|
||||
| Danger-category sound, player already in dialogue | **Not suppressed.** Danger indicators break through dialogue focus. (See note.) |
|
||||
|
||||
**Dialogue suppression exception for Danger:** Per D-070 (confrontation as cognitive vulnerability), ambient sounds are muffled during confrontation/dialogue — but this is audio suppression, not visual suppression. If an alarm fires while the player is in dialogue, the danger indicator should still appear. The player may not hear the alarm (audio is dipped) but the insert catches it. This is the insert doing its job: processing data the character's conscious attention missed.
|
||||
|
||||
**Voice indicator suppression logic:** `event_type` containing "voice"/"speech"/"convers"/"talk" → Voice category. This is already implemented in `color_for_type()` in the current renderer. No change needed.
|
||||
|
||||
---
|
||||
|
||||
## 9. Current Implementation vs Spec — Delta Summary
|
||||
|
||||
| Aspect | Current (v0.1) | Target (spec) |
|
||||
|--------|---------------|---------------|
|
||||
| Shape | Filled arrowhead | Arc segment (40°, 6px radius) |
|
||||
| Animation | Static + linear fade (3.5s total) | Pulse onset + fade (2.5s total) |
|
||||
| Range differentiation | None — all indicators same size | Three levels: 3px scale + alpha |
|
||||
| Z-layer | Needs confirmation | Z-layer 6 (insert overlay) |
|
||||
| Direction | Correct — arrow points toward source | Same principle, different shape |
|
||||
| Colors | Correct — uses Constants palette | Same |
|
||||
| Deduplication | Correct — resets timer on re-trigger | Same |
|
||||
| Insert-off suppression | Not yet implemented | Suppress when `insert_active == false` |
|
||||
|
||||
**v0.1 implementation priority:**
|
||||
1. Confirm z-layer 6 placement (quick fix if wrong)
|
||||
2. Add insert-off suppression
|
||||
3. Add basic range alpha differentiation (single alpha pass by distance)
|
||||
4. Keep arrow shape — replace with arc when sprite art arrives
|
||||
|
||||
---
|
||||
|
||||
## 10. Visual Design Rationale
|
||||
|
||||
**Why at the fog edge, not screen edge or minimap?**
|
||||
|
||||
Screen-edge indicators have no spatial relationship to the game world — they're purely navigational UI. The fog boundary is where the character's perception ends. A sound at the fog edge is a sound at the limit of what the character can process. Placing the indicator there is diegetically honest: the character's insert is flagging something at the edge of their awareness, not beyond it.
|
||||
|
||||
Minimap overlays require a minimap. We don't have one in v0.1. And minimap is meta-game — the indicator at the fog edge is in-world.
|
||||
|
||||
**Why single pulse, not persistent marker?**
|
||||
|
||||
Sound is a moment, not a state. A sound event happens at a point in time and then is over. A persistent marker would imply "there is still a sound here," which isn't necessarily true. The single pulse says "something happened in that direction." The player acts on it or doesn't.
|
||||
|
||||
**Why amber for voice, not a neutral sound color?**
|
||||
|
||||
Because voices are the most important medium-range sound in the game. NPCs talking to each other is signal. Footsteps are noise. Making voice indicators amber — the same as Person of Interest entity color — trains the player to associate amber with "social activity worth paying attention to." The color vocabulary reinforces the relationship system.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Decision Cross-References
|
||||
|
||||
| Decision | Relevance |
|
||||
|----------|-----------|
|
||||
| D-018 | Three-range sound model — source for indicator tier assignment. |
|
||||
| D-047 | Two-tier animation. Tier 2 NPC behaviors produce the sound events that trigger voice indicators. |
|
||||
| D-048 | Insert overlay visual language — indicators are part of the insert, not the world. |
|
||||
| D-049 | Z-level rendering stack — z-layer 6 for insert overlay. |
|
||||
| D-059 | Fog shader. Sound pings in fog are separate from sound indicators (fog layer concentric rings vs insert edge arrows). |
|
||||
| D-070 | Confrontation as cognitive vulnerability — danger indicators not suppressed during dialogue. |
|
||||
|
||||
## Appendix B — Quick Reference for Stig
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Z-layer | 6 (insert overlay) |
|
||||
| Neutral color | `#c8d0e0` |
|
||||
| Voice color | `#e8c547` |
|
||||
| Danger color | `#d45d5d` |
|
||||
| Max alpha | 90% |
|
||||
| Total visible duration (target) | 2.5s |
|
||||
| Total visible duration (current) | 3.5s |
|
||||
| Fade duration | 1.0s ease-in |
|
||||
| Deduplication | Reset timer on same-tile re-trigger |
|
||||
| Range alpha levels | Close-medium 90% / standard 70% / far-medium 45% |
|
||||
| Suppression: within LOS | Yes |
|
||||
| Suppression: insert off | Yes |
|
||||
| Suppression: danger + dialogue | No — danger breaks through |
|
||||
@@ -0,0 +1,229 @@
|
||||
# Tell Visual/Behavioral Expression — Specification
|
||||
|
||||
**Version:** v0.1 (Sprint 14)
|
||||
**Author:** Araminta (Visual Designer)
|
||||
**Date:** 2026-02-20
|
||||
**Ticket:** #251
|
||||
**Status:** Active — visual design input to server-side tell behavior (Dudley, future sprint); constrains monologue content (Mellanie)
|
||||
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§6, animation tier system), [THE FRIEND Visual Treatment](the-friend-visual-treatment.md) (#318), Decisions D-024, D-047
|
||||
|
||||
---
|
||||
|
||||
## 1. What This Document Covers
|
||||
|
||||
The tell system (D-024 axis: Tell System) defines behavioral signals that NPCs exhibit when their underlying state creates tension with their public behavior. A nervous NPC shows nervousness. An angry NPC shows anger. These are not metatextual UI indicators — they are behavioral patterns in the top-down renderer.
|
||||
|
||||
**Araminta's scope:** How tells look at the tile level — what movement patterns, position choices, and timing behaviors constitute each tell category. This is the design input that Dudley uses to implement movement and pathfinding modifiers on the server.
|
||||
|
||||
**Dudley's scope:** Implementing these behaviors as simulation-side movement decisions, pathfinding priority changes, and activity state modifications.
|
||||
|
||||
**Mellanie's scope:** Writing the monologue lines that fire when the player observes a tell via `observe_npc` trigger. In v0.1, tell expression is primarily via **monologue text** — the server emits a monologue trigger when a tell is active and the player observes the NPC.
|
||||
|
||||
**The tell is not visible as a UI element.** There is no icon, no indicator, no animation label. The tell manifests as a behavioral pattern that the player may or may not notice, may or may not interpret correctly. The monologue (if it fires) provides the character's interpretation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tell Categories and Animation Tier Mapping
|
||||
|
||||
D-024 defines the tell system axis on the NPC model. This spec identifies 5 tell categories derived from the axis. Each maps to one or more Tier 2 behavioral expressions (D-047 animation tier system). The mapping is based on what the tell would look like from a top-down view on a character operating in a public space.
|
||||
|
||||
| Tell category | Internal state | Primary Tier 2 behavior | Secondary behavior | Notes |
|
||||
|---------------|---------------|------------------------|-------------------|-------|
|
||||
| **Nervous** | Stress above tolerance threshold, concealment at risk | Movement hesitation + route checking | Proximity avoidance to specific zones | Most common for characters with active secrets |
|
||||
| **Angry** | High stress, contested relationship, tolerance reached | Accelerated/direct movement | Short dwell times | Anger in the Commonwealth is internalized — no outburst in public |
|
||||
| **Friendly** (suppressed) | Wanting to interact but constrained | Lingering near character / zone | Approach-and-withdraw pattern | Friendly tell occurs when NPC wants contact but can't initiate |
|
||||
| **Guarded** | Protective of information or person | Proximity positioning | Route shielding | NPC places themselves between player and something/someone |
|
||||
| **Routine deviation** | Normal routine interrupted by higher priority | Unexpected location, unexpected timing | Unusual activity for current day phase | The broadest tell — anything outside the established pattern |
|
||||
|
||||
---
|
||||
|
||||
## 3. Tier 2 Behavioral Vocabulary
|
||||
|
||||
These are the Tier 2 animation states that serve as tell expressions. Each behavior is observable in the top-down renderer and ambiguous in isolation — the player sees *what* but not *why*.
|
||||
|
||||
### 3.1 Movement Hesitation
|
||||
|
||||
**Appearance:** NPC stops at a tile boundary for 0.5–2.0 seconds before entering a room, doorway, or open space. The pause is distinct from the NPC's Idle animation — it is directional (NPC is facing the destination, not standing neutrally). No heading change during the pause.
|
||||
|
||||
**Ambiguity:** Break? Waiting for someone? Checking the space? Nervous? All are plausible.
|
||||
|
||||
**Server implementation note:** A `PathRequest` with a brief pre-entry wait injected before the final move into the destination tile. Duration variable by stress level (higher stress = longer hesitation, up to 2.0s).
|
||||
|
||||
**Which tells use it:**
|
||||
- **Nervous:** Primary expression. NPC hesitates before entering a space they associate with risk.
|
||||
- **Guarded:** Secondary expression. NPC hesitates before committing to a route past a target zone.
|
||||
|
||||
### 3.2 Route Deviation
|
||||
|
||||
**Appearance:** NPC takes a longer path between two points than the direct route. The direct route passes by a certain person, object, or zone; the NPC routes around it. To a player who doesn't know the direct route, this looks like normal navigation. To a player who has mapped the space, this looks wrong.
|
||||
|
||||
**Ambiguity:** Corridor blocked? Just habit? Avoiding someone? Picking something up along the way?
|
||||
|
||||
**Server implementation note:** Pathfinding with an avoidance weight on specific tiles associated with the avoided entity/zone. The alternate route is valid — just longer than necessary. The NPC arrives at their destination; they just took a detour.
|
||||
|
||||
**Which tells use it:**
|
||||
- **Nervous:** NPCs nervous about a person route around that person.
|
||||
- **Guarded:** NPCs protecting a location route to block others from approaching it via the most direct path.
|
||||
- **Routine deviation:** Primary expression. Route is different from the established pattern for this NPC at this day phase.
|
||||
|
||||
### 3.3 Lingering
|
||||
|
||||
**Appearance:** NPC arrives at a location but does not begin a Tier 1 activity. They stand in the area, possibly using Idle animation, for longer than transit time would explain. The NPC's presence in the location appears unmotivated — they have not started working, eating, or talking.
|
||||
|
||||
**Ambiguity:** Waiting for someone? Thinking? Watching the door? On a break they didn't plan?
|
||||
|
||||
**Server implementation note:** After completing `PathRequest` to a destination, inject an unmotivated `Idle` activity state before assigning the next `DailyRoutine` activity. Duration variable by tell intensity. The NPC is physically present but not executing their scheduled behavior.
|
||||
|
||||
**Which tells use it:**
|
||||
- **Friendly (suppressed):** Primary expression. NPC has reason to want contact with the player but cannot initiate. They linger near the player's likely path.
|
||||
- **Nervous:** Secondary expression. NPC lingers near an exit or junction (choosing a direction).
|
||||
- **Guarded:** Primary expression. NPC lingers near the person or thing they are protecting — present without clear activity.
|
||||
|
||||
### 3.4 Approach-and-Withdraw
|
||||
|
||||
**Appearance:** NPC moves toward a character or location, stops within ~3 tiles, pauses, then moves away without initiating interaction. This is a two-step Tier 2 behavior — approach (pathfinding toward target), hesitation (brief stop), withdrawal (pathfinding to alternate destination). The complete sequence takes 4–8 seconds.
|
||||
|
||||
**Ambiguity:** Changed their mind? Forgot something? Lost nerve? Saw something that made them reconsider?
|
||||
|
||||
**Server implementation note:** Two sequential `PathRequest` instances with an unmotivated `Idle` state (1–2s) between them. First request targets a tile near the goal entity/zone. Second request targets a tile away from it.
|
||||
|
||||
**Which tells use it:**
|
||||
- **Friendly (suppressed):** Primary expression. The NPC wanted to speak with the player but couldn't bring themselves to do it.
|
||||
|
||||
### 3.5 Grouping (Proximity Positioning)
|
||||
|
||||
**Appearance:** NPC persistently positions themselves near a specific entity or zone without a Tier 1 activity justifying the proximity. Unlike lingering (which is about staying in one spot), grouping behavior involves movement that tracks a moving target — the NPC adjusts their position as the other entity moves.
|
||||
|
||||
**Ambiguity:** Friends? Colleagues? Monitoring? Protecting?
|
||||
|
||||
**Server implementation note:** Pathfinding goal shifts dynamically based on target entity position — maintain ~2–3 tile proximity without entering interaction range. This is distinct from escorting (which is intentional) or conversation (which has its own state).
|
||||
|
||||
**Which tells use it:**
|
||||
- **Guarded:** Primary expression. NPC positions themselves between the player and a protected entity.
|
||||
- **Nervous:** Secondary expression. NPC unconsciously stays near someone they trust when stressed.
|
||||
|
||||
### 3.6 Avoidance
|
||||
|
||||
**Appearance:** NPC reacts to the player's (or another entity's) presence by increasing distance. When the player enters a zone, the NPC finds a reason to be in a different part of it, or exits. This is distinguishable from normal zone movement because the timing correlates with the player's arrival — but correlation is not confirmation.
|
||||
|
||||
**Ambiguity:** Coincidental? Habit? Assigned to another area? Afraid?
|
||||
|
||||
**Server implementation note:** On player entry to zone, if tell is active, NPC's current `ActivityTarget` is replaced with one on the far side of the zone or in an adjacent zone. The replacement looks like a routine update — there is no visual signal of the change. Timing threshold: avoidance triggers within 3 ticks of player zone entry.
|
||||
|
||||
**Which tells use it:**
|
||||
- **Nervous:** Secondary expression. NPC avoids the person they are nervous about.
|
||||
- **Guarded:** Secondary expression. NPC moves away from the player to avoid revealing the protected entity by their own proximity.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tell Category → Behavior Matrix
|
||||
|
||||
| Tell category | Movement hesitation | Route deviation | Lingering | Approach/withdraw | Grouping | Avoidance |
|
||||
|---------------|---------------------|----------------|-----------|-------------------|---------|-----------|
|
||||
| **Nervous** | ●● Primary | ● Secondary | ● Secondary | — | ● Secondary | ● Secondary |
|
||||
| **Angry** | — | — | — | — | — | ● Primary |
|
||||
| **Friendly (suppressed)** | — | — | ●● Primary | ●● Primary | — | — |
|
||||
| **Guarded** | ● Secondary | ● Secondary | ●● Primary | — | ●● Primary | ● Secondary |
|
||||
| **Routine deviation** | — | ●● Primary | ●● Primary | — | — | — |
|
||||
|
||||
`●●` = primary expression, `●` = secondary expression, `—` = not used.
|
||||
|
||||
**Angry tell note:** Anger in the Commonwealth is internalized in public spaces. An angry NPC does not have an outburst. They move faster (shorter dwell times, quicker route execution), speak shorter sentences (Mellanie's domain), and avoid the person they are angry with if they can manage it. Avoidance is the primary behavioral tell. There is no raised fist or stamped foot. The station is a workplace; people here manage.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tell Intensity — Scaling the Visual Weight
|
||||
|
||||
Tell behaviors scale in intensity based on the NPC's current stress level or trigger severity. The intensity affects duration and frequency, not the type of behavior.
|
||||
|
||||
| Intensity level | Server trigger | Hesitation duration | Linger duration | Route deviation |
|
||||
|-----------------|---------------|---------------------|----------------|-----------------|
|
||||
| Low | StressAboveThreshold barely met | 0.5s | 3–5s | Minor (+10% path length) |
|
||||
| Medium | Stress significantly elevated | 1.0s | 5–10s | Moderate (+20–30% path length) |
|
||||
| High | DuringActivity / NearSpecificEntity triggered | 1.5–2.0s | 10–15s | Major (+50%+ path length) |
|
||||
|
||||
**High-intensity tells are rare.** If every NPC is visibly nervous all the time, the signal degrades. Tells are most powerful when they occur against a background of normal behavior. The player must earn the observation by paying attention.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tell Recognition — Monologue as the Interpretation Layer
|
||||
|
||||
In v0.1, tell expression in the renderer is the behavior. The monologue is the character's interpretation.
|
||||
|
||||
**The player sees:** An NPC hesitating before entering the cargo office.
|
||||
**The character thinks (monologue):** *"She stopped before going in. Like she needed to decide something."* (Observation tier monologue, `observe_npc` trigger, tell active for Nervous category)
|
||||
|
||||
The monologue fires when:
|
||||
1. The player is observing the NPC (cursor hover / Observe verb)
|
||||
2. A tell is active on that NPC
|
||||
3. The character's knowledge state is sufficient to notice (basic observation — no special knowledge required for surface-level tell recognition)
|
||||
|
||||
The monologue does NOT confirm the tell's meaning. It describes what was seen, in the character's voice, without resolving the ambiguity. The detective's monologue and the smuggler's monologue for the same tell will interpret it through different cognitive registers (Mellanie's domain).
|
||||
|
||||
---
|
||||
|
||||
## 7. THE FRIEND's Tell Trajectory
|
||||
|
||||
### 7.1 Kael Davan (Smuggler's FRIEND)
|
||||
|
||||
**Pre-discovery:** Kael exhibits routine deviation tell — he has a regular dock schedule but occasionally takes longer routes. In Phase 1 and Phase 2, this is unnoticed or attributed to route preference. The tell is present from session start.
|
||||
|
||||
**Contradiction seen:** Kael in restricted corridor meeting. The player observes the meeting itself — this is not a tell but a direct observation. After the meeting, Kael's nervous tell activates at higher intensity (movement hesitation, avoidance of the player). The behaviors the player may have noticed but ignored before now carry new meaning.
|
||||
|
||||
**Post-discovery:** Kael's tell pattern becomes legible. The approach-and-withdraw the player may have seen once (Kael walking toward the dock, pausing, turning) now reads as Kael wanting to say something but being unable to. The player interprets the same behavior through a new frame.
|
||||
|
||||
### 7.2 Sera Venn (Detective's FRIEND)
|
||||
|
||||
**Pre-discovery:** Sera exhibits avoidance tell around Torek Lintar. She routes around him, leaves when he enters The Last Shift. This is observable from the start — but without context, the player reads it as social preference.
|
||||
|
||||
**Contradiction understood:** The player makes the connection: Sera's avoidance of Torek correlates with Kael's manifest discrepancies. Sera knows something and is avoiding the person who would use that information.
|
||||
|
||||
**Post-discovery:** Sera's guarded tell (proximity positioning near Naia, avoidance of Torek and the detective) becomes legible as protective behavior. The detective character has a trained observation skill — they should notice this. The monologue should reflect recognition ("*She's not avoiding him. She's keeping herself between him and something.*").
|
||||
|
||||
---
|
||||
|
||||
## 8. Scope Boundaries
|
||||
|
||||
**In scope (Araminta / this spec):**
|
||||
- What tells look like as tile-level movement patterns
|
||||
- Mapping of tell categories to Tier 2 behaviors
|
||||
- Intensity scaling guidance
|
||||
|
||||
**In scope (Dudley / server implementation):**
|
||||
- Implementation of movement hesitation via PathRequest wait injection
|
||||
- Route deviation via pathfinding avoidance weights
|
||||
- Lingering via unmotivated Idle activity state
|
||||
- Approach-and-withdraw via two sequential PathRequests with pause
|
||||
- Grouping via dynamic proximity pathfinding target
|
||||
- Avoidance via zone-entry reactive ActivityTarget replacement
|
||||
- TellSystem component and TellTrigger matching (`StressAboveThreshold`, `NearSpecificEntity`, `DuringActivity`, `TimeOfDay`, `Always`)
|
||||
|
||||
**In scope (Mellanie / monologue content):**
|
||||
- Monologue lines for each tell category, per character (`observe_npc` trigger + tell active)
|
||||
- Maintaining ambiguity in the monologue — describing behavior without confirming intent
|
||||
- Character-differentiated interpretation (detective reads analytically; smuggler reads socially)
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Decision Cross-References
|
||||
|
||||
| Decision | Relevance |
|
||||
|----------|-----------|
|
||||
| D-024 | NPC generation model. Tell system axis definition. 5 tell categories. TellTrigger model. |
|
||||
| D-047 | Two-tier animation system. All tell behaviors are Tier 2 expressions. |
|
||||
| D-033 | Entity color. No tell modifies entity color — tells are behavioral, not chromatic. |
|
||||
| D-016 | Internal monologue as perception bridge. Monologue fires on observed tell. |
|
||||
| D-044 | Visual hierarchy. Tells are behavioral, not decorative — no overlays, no icons. |
|
||||
|
||||
## Appendix B — Quick Reference for Dudley
|
||||
|
||||
| Tier 2 behavior | Server mechanism | Trigger |
|
||||
|----------------|-----------------|---------|
|
||||
| Movement hesitation | Pre-entry wait in PathRequest | StressAboveThreshold, NearSpecificEntity |
|
||||
| Route deviation | Pathfinding avoidance weight on specific tiles | StressAboveThreshold, DuringActivity |
|
||||
| Lingering | Unmotivated Idle between PathRequest and DailyRoutine activity | NearSpecificEntity, Always (for guarded) |
|
||||
| Approach-and-withdraw | Two sequential PathRequests + Idle (1–2s) between | NearSpecificEntity (friendly suppressed) |
|
||||
| Grouping / proximity positioning | Dynamic pathfinding target tracking entity | NearSpecificEntity (guarded) |
|
||||
| Avoidance | Zone-entry reactive ActivityTarget replacement | NearSpecificEntity, StressAboveThreshold |
|
||||
|
||||
**Intensity scaling:** Hesitation duration 0.5s / 1.0s / 1.5–2.0s. Linger duration 3–5s / 5–10s / 10–15s. Route deviation +10% / +20–30% / +50%+ path length. Intensity driven by stress value distance above threshold.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Text Display Hierarchy — Visual Specification
|
||||
|
||||
**Version:** v0.1 (Sprint 14)
|
||||
**Author:** Araminta (Visual Designer)
|
||||
**Date:** 2026-02-20
|
||||
**Ticket:** #316
|
||||
**Status:** Active — extends monologue display spec (#315); constrains client #122 implementation
|
||||
**Foundation:** [Visual Grammar v0.1](visual-grammar-v01.md) (§5), [Monologue Display Spec](monologue-display-spec.md) (#315)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview — Four Content Pipelines
|
||||
|
||||
The game has four distinct text channels, each with different visual identity, screen position, z-layer, and content source. They must be immediately distinguishable from each other at a glance — not through decoration but through consistent placement, size, and color rules.
|
||||
|
||||
| Pipeline | Source | Screen position | Z-layer | Content register |
|
||||
|----------|--------|----------------|---------|-----------------|
|
||||
| **Dialogue** | NPC speech + player response options | Bottom of screen | 7 | External — what is said aloud |
|
||||
| **Internal monologue** | Player character's inner voice | Lower-left, above dialogue | 7 | Internal — what the character thinks |
|
||||
| **Observation / overheard** | Perceived NPC speech, passive panel | Bottom of screen, passive mode | 7 | Filtered external — what the player overhears |
|
||||
| **Environmental text** | Signage, terminals, news tickers | World layer, in-scene | 2–4 | Diegetic — part of the physical world |
|
||||
|
||||
The dialogue and observation pipelines share the same screen container (bottom panel) but in different modes. Internal monologue occupies the lower-left quadrant. Environmental text lives in the world, not in any HUD layer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pipeline 1 — Dialogue
|
||||
|
||||
### 2.1 Layout
|
||||
|
||||
Per D-061 and D-076:
|
||||
|
||||
| Property | Value | Notes |
|
||||
|----------|-------|-------|
|
||||
| Screen position | Bottom center | Centered horizontally, anchored to bottom edge |
|
||||
| Max height | 20% of screen height | 216px at 1080p |
|
||||
| Max width | 640px | Per D-076. Grid-aligned (20 × 32px tile). Centered. |
|
||||
| Layout direction | NPC speech on top, response options below | Left-aligned within the box |
|
||||
| Max visible response options | 3 | Locked options invisible — D-062 |
|
||||
| Portraits | None | NPC is on screen. Portrait is redundant. |
|
||||
|
||||
### 2.2 Typography
|
||||
|
||||
| Element | Size | Hex | Opacity | Weight |
|
||||
|---------|------|-----|---------|--------|
|
||||
| NPC speech | 16px Michroma | `#e8eaf0` | 100% | Regular 400 |
|
||||
| NPC speaker name | 14px Michroma | D-033 color of NPC | 90% | Regular 400 |
|
||||
| Player response option | 14px Michroma | `#c0c8d8` | 90% | Regular 400 |
|
||||
| Confrontation option | 15px Michroma, italic | `#e0e8f0` | 100% | Regular 400 |
|
||||
|
||||
**Speaker name treatment:** The NPC's name renders in their current D-033 relationship color — teal, green, amber, or red. This is the one place where the D-033 color system bleeds into the text layer and it is intentional: the player sees "Kael" in green and "Sera" in amber and reads that immediately.
|
||||
|
||||
**Confrontation options** are italicized and 1px larger than standard response options. They express the character's internal voice speaking aloud — they should feel heavier than regular dialogue choices. No bold. Italic alone signals the weight.
|
||||
|
||||
### 2.3 Box Framing
|
||||
|
||||
The dialogue box has minimal framing — not a heavy panel, not floating text:
|
||||
- Thin 1px border at `#333340` (standard outline color) at 60% opacity
|
||||
- Background: `#0e1218` at 75% opacity (matches Terminal zone ambient, cool neutral dark)
|
||||
- No rounded corners — geometric, insert-styled
|
||||
- Box is full max-width (640px) even when content is shorter — consistent spatial expectation
|
||||
|
||||
### 2.4 Passive Mode (Overheard Conversation — D-078)
|
||||
|
||||
When the player is overhearing an NPC-to-NPC conversation, the same box renders in **passive mode**:
|
||||
- No response options rendered
|
||||
- Header row: `[NPC_A] → [NPC_B]` in their respective D-033 colors, at 13px, 70% opacity
|
||||
- Overheard speech line at 16px, but at 70% opacity (vs 100% for direct dialogue)
|
||||
- 1px border at `#333340` at **40% opacity** (dimmed vs interactive 60%) — visual signal that this is passive
|
||||
- Occluded words rendered as `...` in `#555566` (fog blob grey) — the player sees the gap
|
||||
|
||||
Walking out of earshot dismisses the panel naturally (no close button, same as direct dialogue).
|
||||
|
||||
### 2.5 Walk-Away Fade
|
||||
|
||||
When the player walks away (WASD during dialogue), the dialogue box fades over **300ms** (D-064). No close button. No "dismiss" verb. Walking away is the action.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pipeline 2 — Internal Monologue
|
||||
|
||||
Per the full specification in [Monologue Display Spec](monologue-display-spec.md) (#315). This section summarizes the key positioning rules for hierarchy legibility.
|
||||
|
||||
### 3.1 Position Summary
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Horizontal anchor | Left edge + 5% screen width margin |
|
||||
| Vertical anchor | 25% from screen bottom (above dialogue box + 5% gap) |
|
||||
| Max text width | 50% screen width (960px at 1920px) |
|
||||
| Z-layer | 7 |
|
||||
| Font | 13px Michroma |
|
||||
| Stack direction | Bottom-up (newest line at bottom) |
|
||||
| Max visible lines | 3 |
|
||||
|
||||
### 3.2 Relationship to Dialogue Box
|
||||
|
||||
Monologue floats **above** the dialogue box, always. When dialogue is inactive, monologue remains anchored at 25% from bottom — it does not slide down into the vacated space. Consistent lower-left positioning trains the player to read the lower-left as inner thought.
|
||||
|
||||
### 3.3 Character Colors
|
||||
|
||||
| Character | Standard | Urgent |
|
||||
|-----------|----------|--------|
|
||||
| Detective | `#d0d4e0` at 85% | `#e0e8f8` at 100% |
|
||||
| Smuggler | `#d8d0c4` at 85% | `#f0e4d4` at 100% |
|
||||
|
||||
Full fade/timing specification is in the Monologue Display Spec. These values are restated here for hierarchy reference only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pipeline 3 — Observation / Overheard
|
||||
|
||||
The observation pipeline has two sub-modes:
|
||||
|
||||
**Overheard NPC-to-NPC conversation:** Rendered in the passive dialogue panel (see §2.4). Same screen container, different visual mode (dimmed border, no response options, header speaker attribution, stochastic word-drop via `...`).
|
||||
|
||||
**Non-conversation observation text:** When the player perceives something worth narrating — an NPC's behavior, environmental detail, non-verbal event — this routes through the **internal monologue system** via the `observe_npc`, `observe_anomaly`, or `witness_interaction` triggers. These render as standard monologue lines (§3) per the Monologue Display Spec. They are NOT a separate visual element.
|
||||
|
||||
**Design note:** Observation and monologue share the same rendering pipeline because from the character's POV, observation IS internal monologue. The character notices something → the character thinks about it → the same text appears in the same position. The authoring side distinguishes them via trigger type; the rendering side does not.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pipeline 4 — Environmental Text
|
||||
|
||||
Environmental text is part of the **world layer**, not the HUD. It renders as if physically present in the scene — on surfaces, above terminals, in scrolling tickers.
|
||||
|
||||
### 5.1 Z-Layer Assignment
|
||||
|
||||
| Sub-type | Z-layer | Notes |
|
||||
|----------|---------|-------|
|
||||
| Floor signage (painted markings) | 0–1 | On the floor, walked over |
|
||||
| Wall signage | 2 (with furniture/objects) | On object layer, y-sorted |
|
||||
| Terminal ambient display | 4 (overhead layer) | Above entities. Semi-transparent. |
|
||||
| News ticker | 4 (overhead layer) | Same as terminal ambient |
|
||||
|
||||
Environmental text is in the world, not in the HUD layers (6–7). It **must not occlude entities**: anything on z-layer 4 renders at semi-transparent (~70% opacity per the overhead occlusion rule, D-049 §4.4). If an entity passes behind a sign, the sign's opacity means the entity remains partially visible.
|
||||
|
||||
### 5.2 Typography
|
||||
|
||||
All environmental text uses **Michroma** — no exceptions. The fiction is that all text is mediated through the character's neural insert perception layer. Even handwritten signs are rendered in Michroma at appropriate size/opacity.
|
||||
|
||||
| Sub-type | Size | Hex | Opacity | Notes |
|
||||
|----------|------|-----|---------|-------|
|
||||
| Zone signage (short) | 11px | `#8899aa` | 70% | 1–3 words, spatial label |
|
||||
| Terminal ambient ID | 11px | `#8899aa` | 60% | Equipment identifier, visible at range |
|
||||
| Terminal active (readable) | 13px | `#a8b8c8` | 85% | When player is adjacent and interacts |
|
||||
| News ticker | 10px | `#8899aa` | 55% | Scrolling. Ambient, not blocking. |
|
||||
| Informal signage | 11px | `#9aa890` | 65% | Slightly warmer/greener hue for social zones |
|
||||
|
||||
The environmental text color range (`#8899aa` to `#9aa890`) sits clearly below entity saturation — muted blue-greys and grey-greens. They are legible without competing with entities.
|
||||
|
||||
### 5.3 Two-State Terminals
|
||||
|
||||
Terminals have two visual states:
|
||||
|
||||
**Ambient state (player at range):**
|
||||
- Terminal displays an ambient identifier — short text, equipment type or designation
|
||||
- 11px Michroma at `#8899aa`, 60% opacity
|
||||
- Rendered on z-layer 4 (overhead) above the terminal sprite
|
||||
- Visible from up to ~4 visual tiles (sufficient to plan approach)
|
||||
|
||||
**Active state (player adjacent, Observe/Interact verb triggered):**
|
||||
- Full terminal text readable
|
||||
- 13px Michroma at `#a8b8c8`, 85% opacity
|
||||
- Displayed in a small floating text block anchored above the terminal sprite
|
||||
- Max width: 200px. Text wraps. Capped at ~12 lines of content.
|
||||
- Same z-layer 4, but increased opacity signals active state
|
||||
- Dismissed when player moves away (same as dialogue walk-away — no close button)
|
||||
|
||||
### 5.4 Bilingual Treatment (D-036)
|
||||
|
||||
The Krenn System has two linguistic registers:
|
||||
|
||||
**Concordat Standard** — the colonial lingua franca. Neutral, bureaucratic, formal. Written in normal Michroma weight.
|
||||
|
||||
**Krenn vernacular** — the local dialect. Compact, consonant-heavy, social. Rendered in the same Michroma but at slightly lower opacity (60% vs 70%) and, when design calls for it, in the informal signage color (`#9aa890` greener hue). This subtle warmth distinguishes the local voice from the colonial layer.
|
||||
|
||||
| Context | Language | Color | Opacity |
|
||||
|---------|----------|-------|---------|
|
||||
| Formal facility signage | Concordat Standard | `#8899aa` | 70% |
|
||||
| Equipment identifiers | Concordat Standard | `#8899aa` | 60% |
|
||||
| Social zone informal signs | Krenn vernacular | `#9aa890` | 65% |
|
||||
| Bar menu, worker notices | Krenn vernacular | `#9aa890` | 65% |
|
||||
| Mixed audience (official but social) | Concordat Standard primary, Krenn secondary at 50% opacity | Both | Stacked, smaller |
|
||||
| News ticker | Concordat Standard | `#8899aa` | 55% |
|
||||
|
||||
**Where both languages appear together:** Formal signage targeting mixed audiences renders the primary language at standard opacity, with a smaller Krenn vernacular translation at 50% opacity below it. The two-layer approach communicates the bilingual reality of the district without requiring two UI elements — it is one sign with two registers.
|
||||
|
||||
### 5.5 News Tickers
|
||||
|
||||
News tickers are ambient environmental text elements attached to specific terminal or screen fixtures in the scene. They are **not** HUD overlays.
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Position | Anchored above the emitting fixture sprite |
|
||||
| Z-layer | 4 |
|
||||
| Width | Width of the fixture sprite (typically 64–128px) |
|
||||
| Font size | 10px Michroma |
|
||||
| Color | `#8899aa` at 55% opacity |
|
||||
| Scroll behavior | Right-to-left. Speed: ~30px/second. Loops. |
|
||||
| Pause on player proximity | When player within 2 visual tiles, scroll pauses. |
|
||||
| Language | Concordat Standard |
|
||||
|
||||
News tickers are ambient information — not intended to be read at a glance. The player has to choose to stand near one to catch the scrolling text. This is intentional: news is environmental texture, not tutorial.
|
||||
|
||||
---
|
||||
|
||||
## 6. Hierarchy Clarity Rules
|
||||
|
||||
These are the rules that keep the four pipelines visually distinct without active management:
|
||||
|
||||
1. **Position is identity.** Lower-left = inner thought. Bottom center = speech (active or overheard). In-world = physical reality. Players learn this in 5–10 minutes and stop reading position consciously.
|
||||
|
||||
2. **Opacity descends from HUD to world.** HUD text (monologue, dialogue): 85–100%. Environmental text: 55–85%. The world is legible but subordinate to what the character is actively processing.
|
||||
|
||||
3. **Size descends from speech to environment.** Dialogue speech: 16px. Monologue: 13px. Environmental text: 10–13px. The most important active channel is always the largest.
|
||||
|
||||
4. **Color temperature signals register.** Cool grey-blue (`#c0c8d8` and variants) = system/HUD/player-facing. Warm cream variants = character voice (smuggler). Muted blue-grey (`#8899aa`) = world/environment.
|
||||
|
||||
5. **D-033 colors in text only for speaker names.** NPC name in dialogue rendered in their relationship color. No other text element uses D-033 colors — those are reserved for entity sprites.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Decision Cross-References
|
||||
|
||||
| Decision | Relevance |
|
||||
|----------|-----------|
|
||||
| D-016 | Internal monologue as core perception system. Source for monologue pipeline. |
|
||||
| D-036 | Sova Transit District + Krenn vernacular. Bilingual treatment basis (§5.4). |
|
||||
| D-049 | Z-level rendering stack. Z-layer assignments for all four pipelines. |
|
||||
| D-061 | Dialogue box layout. Source for §2. |
|
||||
| D-062 | Invisible locked dialogue — no locked options visible. |
|
||||
| D-064 | Walk-away — 300ms dialogue fade, WASD dismissal. |
|
||||
| D-076 | Dialogue max-width = 640px. |
|
||||
| D-078 | Overheard NPC conversation — passive dialogue panel with occlusion filter. |
|
||||
|
||||
## Appendix B — Quick Reference for Stig
|
||||
|
||||
| Pipeline | Position | Z-layer | Font size | Primary hex |
|
||||
|----------|----------|---------|-----------|------------|
|
||||
| Dialogue — NPC | Bottom center, 640px wide | 7 | 16px | `#e8eaf0` |
|
||||
| Dialogue — player response | Bottom center, below NPC | 7 | 14px | `#c0c8d8` |
|
||||
| Dialogue — passive header | Bottom center, header row | 7 | 13px | D-033 colors |
|
||||
| Monologue — standard | Lower-left, 25% from bottom | 7 | 13px | Per character |
|
||||
| Environmental — signage | World / object layer | 2–4 | 11px | `#8899aa` |
|
||||
| Environmental — terminal active | World / overhead layer | 4 | 13px | `#a8b8c8` |
|
||||
| Environmental — news ticker | World / overhead layer | 4 | 10px | `#8899aa` |
|
||||
@@ -0,0 +1,209 @@
|
||||
# THE FRIEND — Visual Treatment Specification
|
||||
|
||||
**Version:** v0.1 (Sprint 14)
|
||||
**Author:** Araminta (Visual Designer)
|
||||
**Date:** 2026-02-20
|
||||
**Ticket:** #318
|
||||
**Status:** Active — visual design input to server and copy team; constrains #251 (tell visual expression)
|
||||
**Foundation:** [Entity Color System](entity-color-system.md) (#304), [Visual Grammar v0.1](visual-grammar-v01.md), Decisions D-034, D-033, D-047, D-027
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Principle
|
||||
|
||||
Per D-034: **"Phase 1 identical to other friendly NPCs. Earned visual detail only."**
|
||||
|
||||
THE FRIEND does not receive special visual marking. There is no halo, no highlight, no indicator that says "this NPC is important." The player's emotional investment in THE FRIEND is built entirely through story and interaction — accumulated time, dialogue, observed routine. Visual differentiation accrues through narrative, not through marking.
|
||||
|
||||
This is non-negotiable. If the game tells the player "this NPC matters," it removes the uncertainty that makes the eventual contradiction devastating. The detective should be conflicted not because the game flagged Sera as special, but because *they made her special through their own attention*.
|
||||
|
||||
The wow moment (D-027 success criterion #3: "player names an NPC they felt conflicted about") depends entirely on the visual system having been honest. THE FRIEND looked exactly like any other known/friendly NPC throughout. That's why the color shift lands.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Two FRIENDs
|
||||
|
||||
**Smuggler's FRIEND: Kael Davan** — dock worker, ring member, the smuggler's closest colleague. Contradiction: secret meeting with unknown contact in restricted corridor (trying to exit the ring to protect his partner Naia).
|
||||
|
||||
**Detective's FRIEND: Sera Venn** — Commission field tech, bar regular, the detective's social anchor. Contradiction: avoids Torek Lintar while sitting on unreported evidence about Kael's manifest discrepancies (protecting her friend Naia).
|
||||
|
||||
Both FRIENDs follow the same visual pattern. The spec applies to both. Where character-specific detail is needed, it is noted.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 1 — Before Relationship Builds (Identical to Any NPC)
|
||||
|
||||
### 3.1 Visual State
|
||||
|
||||
Phase 1 begins when the player first encounters THE FRIEND. It ends when the player has had 3+ meaningful interactions that build trust.
|
||||
|
||||
| Property | Value | Notes |
|
||||
|----------|-------|-------|
|
||||
| Entity color | `#6bc9a6` (Known/Friendly green) | Standard D-033 Known state |
|
||||
| Entity size | 24×32px art, 64×64 canvas | Standard entity size |
|
||||
| Outline | 2px `#333340` | Standard entity outline |
|
||||
| Animation tier | Tier 1 | Public daily activities — readable |
|
||||
| Silhouette feature | One identifying feature | See §3.2 |
|
||||
| Insert bloom | Soft halo at D-033 green, 2–3px gaussian | Standard insert treatment |
|
||||
|
||||
THE FRIEND is green. Exactly like any other known/friendly NPC. The player has no way to distinguish them from Lera (bar owner), from a dock colleague, from any other Known relationship.
|
||||
|
||||
### 3.2 Identifying Silhouette Feature
|
||||
|
||||
Each named NPC has one identifying silhouette feature (D-044) that allows recognition by shape before color is processed. These features are design-level decisions — they are built into the NPC sprite and remain consistent throughout all phases.
|
||||
|
||||
| NPC | Silhouette feature | Notes |
|
||||
|-----|-------------------|-------|
|
||||
| Kael Davan | Vest ridge | The dock-worker vest creates a shoulder-width silhouette difference from generic workers |
|
||||
| Sera Venn | Uniform collar | Commission field tech uniform collar — distinguishes her from bar-regulars in plainclothes |
|
||||
| Lera Sessik | Apron shape | Bar owner's apron creates distinctive hip-width silhouette |
|
||||
|
||||
**The silhouette feature is NOT a marker.** It is simply what makes Kael look like Kael and not like a generic dock worker. The player identifies him by shape, same as they identify any person in a crowd by how they carry themselves.
|
||||
|
||||
In Phase 1, the player will not yet know who Kael is. They see a green entity with a vest ridge. After one or two interactions, they associate vest-ridge = Kael. The identification system builds through play, not through a label.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 2 — Relationship Building (3+ Interactions, Trust Accruing)
|
||||
|
||||
### 4.1 Visual State
|
||||
|
||||
Phase 2 is entered after the player has had sufficient meaningful interactions with THE FRIEND that trust is accruing (per the server's trust progression system, #324). The player now has a genuine social relationship — not just an acquaintance, but someone they've spent time with.
|
||||
|
||||
**Visual changes in Phase 2: none to the entity itself.**
|
||||
|
||||
The entity remains green. The silhouette remains the same. No new markers appear.
|
||||
|
||||
### 4.2 What "Earned Visual Detail" Means
|
||||
|
||||
"Earned visual detail" does not mean the entity sprite changes. It means the player has built up contextual knowledge that makes THE FRIEND *visually meaningful* even without special marking.
|
||||
|
||||
The player now knows:
|
||||
- Where Kael usually is at what time of day (routine familiarity)
|
||||
- What Kael's silhouette looks like from across a room (pattern recognition)
|
||||
- Which zone Kael belongs to (environmental anchoring)
|
||||
|
||||
This contextual knowledge is itself a form of visual differentiation — not rendered, but real. When the player sees a green vest-ridge entity at the docking terminal at 0800, they don't need a marker to know it's Kael. They've learned it.
|
||||
|
||||
### 4.3 Object Layer Detail (D-052)
|
||||
|
||||
Per D-052, each NPC has a "favorite color" expressed through personal objects — not entity sprites. By Phase 2, the player may have observed Kael's personal space and noted object-layer details: his particular mug color, the color of his dock-worker ID lanyard, the worn cargo blanket he keeps near his work station.
|
||||
|
||||
These are NOT visual markers. They are world texture. But they are the kind of visual detail that makes a person a *person* rather than an entity. The player remembers the mug color. When they see the mug on the table in the maintenance corridor, they know Kael was here.
|
||||
|
||||
This is earned visual recognition through accumulated observation — exactly what the principle intends.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 3 — Contradiction Discovered (PersonOfInterest Transition)
|
||||
|
||||
### 5.1 The Moment
|
||||
|
||||
The contradiction is discovered through player action — observing Kael in the restricted corridor meeting, finding Sera avoiding Torek in a way that doesn't add up. The discovery is not scripted with a cutscene. The player sees it happen in the normal top-down view.
|
||||
|
||||
At the moment of contradiction discovery, THE FRIEND's relationship state transitions from `Known/Friendly` to `PersonOfInterest` on the server's knowledge graph. This triggers the D-033 color change.
|
||||
|
||||
### 5.2 Color Transition Specification
|
||||
|
||||
| Property | Value | Notes |
|
||||
|----------|-------|-------|
|
||||
| From color | `#6bc9a6` (Known/Friendly green) | The color the player has seen for hours |
|
||||
| To color | `#e8c547` (PersonOfInterest amber) | |
|
||||
| Transition duration | 0.5 seconds | Standard D-033 fade duration |
|
||||
| Transition type | Linear color lerp | Same as all relationship transitions |
|
||||
| When triggered | On server's `PersonOfInterest` state delivery | Client receives updated `RelationshipState` in snapshot |
|
||||
|
||||
**0.5 seconds is the right duration here.** It is long enough to be perceptible — the player watches green become amber, they don't blink and miss it. It is short enough to feel immediate rather than gradual. This is the one relationship color change the game has been building toward. It must land.
|
||||
|
||||
### 5.3 Staging Requirement
|
||||
|
||||
This is THE FRIEND's first color change. It must be the first relationship color change the player has seen in the session.
|
||||
|
||||
The opening 20–25 minutes of gameplay must not trigger any other NPC's relationship state change. No other NPC should transition between any two D-033 states before THE FRIEND's green-to-amber shift. This is a level design and scripting constraint, not a rendering constraint — it is satisfied by game design, not by visual code.
|
||||
|
||||
**Why this matters:** The player must have already learned what green means. They must associate `#6bc9a6` with "trusted person I know" before amber can mean "this trusted person has complicated things." Without the reference point, the color shift is just a color shift.
|
||||
|
||||
### 5.4 Post-Transition Visual State
|
||||
|
||||
Once PersonOfInterest amber is active, THE FRIEND renders with:
|
||||
|
||||
| Property | Value | Notes |
|
||||
|----------|-------|-------|
|
||||
| Entity color | `#e8c547` | Amber |
|
||||
| Insert halo | 2–3px gaussian bloom, amber | Standard insert treatment for amber |
|
||||
| Outline | 2px `#333340` | Unchanged |
|
||||
| Silhouette feature | Unchanged | Vest ridge / uniform collar — identity persists |
|
||||
|
||||
No additional markers. No special framing. The player's relationship to this entity has changed; the entity itself has not changed. The amber is the signal, not a badge.
|
||||
|
||||
---
|
||||
|
||||
## 6. Animation Tier Transition
|
||||
|
||||
### 6.1 Before Contradiction Discovery — Tier 1
|
||||
|
||||
Until the contradiction is discovered, THE FRIEND operates in **Tier 1 animation**: public daily activities, instantly readable, clearly motivated. The player can observe Kael working cargo and understand "he is working." They can observe Sera at the bar and understand "she is relaxing after shift."
|
||||
|
||||
Tier 1 behavior is readable by design. This is important: the player needs to feel like they *know* Kael before knowing him becomes complicated.
|
||||
|
||||
### 6.2 The Transition Point
|
||||
|
||||
**THE FRIEND enters Tier 2 animation when the player has seen the contradiction.**
|
||||
|
||||
Not before. Not based on a timer or tick count. The trigger is epistemic — it is not when Kael has the secret meeting (that always happens), but when the player observes it. The contradiction exists in the simulation from session start; the visual weight of it is unlocked by the player's discovery.
|
||||
|
||||
Before discovery: Kael walks, works, drinks at Lera's. All Tier 1. Clearly motivated.
|
||||
|
||||
After discovery: Kael still walks and works. But now when he pauses near a doorway, the player reads it differently. The behaviors themselves haven't changed — Kael always paused near doorways sometimes. The player's interpretive frame has changed.
|
||||
|
||||
**This is the Tier 2 mechanic expressed perfectly:** the animation tier boundary is invisible. The player experiences the shift subjectively, not through a visual mode change.
|
||||
|
||||
### 6.3 Behaviors That Become Legible as Tier 2
|
||||
|
||||
Once the player has seen the contradiction, previously Tier 1-readable Kael behaviors become Tier 2-legible. The same animation serves both interpretations:
|
||||
|
||||
| Behavior | Pre-discovery reading | Post-discovery reading |
|
||||
|----------|----------------------|----------------------|
|
||||
| Pausing near corridor B-7 | Break / distracted | Checking if the coast is clear |
|
||||
| Looking around | Habit / awareness | Watching for the player |
|
||||
| Lingering near a crate | Moving cargo / waiting | Exchanging something |
|
||||
| Taking an alternate path | Shortest route was blocked | Avoiding someone |
|
||||
|
||||
The simulation may or may not actually be running the Tier 2 intent behind these behaviors — that's Dudley's domain. What matters for the visual spec is that the player's interpretive state changes the meaning of any Tier 2 behavior they observe.
|
||||
|
||||
### 6.4 Monologue Frequency
|
||||
|
||||
Per D-063: post-confrontation, monologue frequency spikes and available topics narrow. This is not a visual change on the entity but is part of the player's experience of THE FRIEND post-discovery. The character's inner voice reacts. This is specified in the copy team's monologue authoring contracts — reference here for completeness.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-Reference: Entity Color System
|
||||
|
||||
THE FRIEND's color transitions are fully governed by the entity color system ([Entity Color System spec](entity-color-system.md), #304). The visual system does not need to know about THE FRIEND specifically — it responds to the relationship state the server delivers.
|
||||
|
||||
The visual spec obligation is: ensure the staging conditions are met so that THE FRIEND's green-to-amber is the first relationship color change. After that, the color system handles everything automatically.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Phased Summary Table
|
||||
|
||||
| Phase | Trigger | Entity color | Animation tier | Visual changes |
|
||||
|-------|---------|-------------|----------------|----------------|
|
||||
| Phase 1: Acquaintance | First visible | `#6bc9a6` green | Tier 1 | None. Identical to any Known NPC. |
|
||||
| Phase 2: Trust building | 3+ meaningful interactions | `#6bc9a6` green | Tier 1 | None on entity. Player's contextual knowledge accumulates. |
|
||||
| Phase 3a: Contradiction seen | Player observes contradiction | Transitioning | Tier 1 still | 0.5s green → amber (`#e8c547`) fade |
|
||||
| Phase 3b: Post-discovery | Amber state locked | `#e8c547` amber | Tier 2 behaviors now legible | Amber sustained. No additional markers. |
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — Decision Cross-References
|
||||
|
||||
| Decision | Relevance |
|
||||
|----------|-----------|
|
||||
| D-027 | Vertical slice success criteria. #3: "player names an NPC they felt conflicted about" — this spec serves that criterion. |
|
||||
| D-033 | Entity color = relationship to player. Source of truth for all color values in this spec. |
|
||||
| D-034 | THE FRIEND production NPC pattern. Source for phase structure and character profiles. |
|
||||
| D-044 | Visual hierarchy. Entity always dominant. Silhouette feature as recognition signal. |
|
||||
| D-047 | Two-tier animation system. Tier 1/Tier 2 boundary, invisible to player. |
|
||||
| D-052 | Character favorite colors — object-layer identification. Phase 2 earned visual detail. |
|
||||
| D-063 | Confrontation text styling — italic first-person options, 1.5s monologue beat. Post-discovery monologue behavior (not visual, but contextually related). |
|
||||
@@ -234,7 +234,7 @@ All sizes are in pixels at 1080p (1920×1080) base resolution. Godot 4 handles D
|
||||
|
||||
| Text role | Size | Opacity | Color | Z-layer | Notes |
|
||||
|-----------|------|---------|-------|---------|-------|
|
||||
| Dialogue — NPC speech | 16px | 100% | `#e8eaf0` | 7 | Max width ~70% screen width. Left-aligned. |
|
||||
| Dialogue — NPC speech | 16px | 100% | `#e8eaf0` | 7 | Max width 640px (~33% at 1080p, per D-076). Left-aligned. |
|
||||
| Dialogue — player response | 14px | 90% | `#c0c8d8` | 7 | Below NPC speech. Up to 3 options visible. |
|
||||
| Monologue (standard) | 13px | 85% | `#d0d4e0` | 7 | Floats above dialogue box. Inner voice — slightly dimmer than dialogue. |
|
||||
| Monologue (urgent) | 13px | 100% | `#e0e8f8` | 7 | Same size, full opacity, slight bloom pulse. Urgent chime accompanies. |
|
||||
|
||||
@@ -348,7 +348,8 @@ fn run_dialogue(index: &LinePoolIndex, args: &Args) {
|
||||
"situation",
|
||||
"arrival, shift_start, shift_end, shift_transition, bar_evening, \
|
||||
night_shift, investigation, confrontation, social, alone, \
|
||||
emergency, routine, observation, greeting",
|
||||
emergency, routine, observation, greeting, first_meeting, \
|
||||
repeated_visit",
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
@@ -597,6 +598,8 @@ fn situation_str(s: &Situation) -> &'static str {
|
||||
Situation::Routine => "routine",
|
||||
Situation::Observation => "observation",
|
||||
Situation::Greeting => "greeting",
|
||||
Situation::FirstMeeting => "first_meeting",
|
||||
Situation::RepeatedVisit => "repeated_visit",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,6 +300,8 @@ mod tests {
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
sound_events: vec![],
|
||||
rng_seed: None,
|
||||
}
|
||||
@@ -425,6 +427,8 @@ mod tests {
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
sound_events: vec![],
|
||||
rng_seed: None,
|
||||
};
|
||||
|
||||
@@ -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 = 11;
|
||||
pub const PROTOCOL_VERSION: u8 = 12;
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
/// Contains all information visible to the observer at a given tick.
|
||||
@@ -31,10 +31,11 @@ pub const PROTOCOL_VERSION: u8 = 11;
|
||||
/// 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).
|
||||
/// v12 adds: conversation_events, conversation_ended (#247, D-078 NPC-to-NPC conversations).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 11.
|
||||
/// Protocol version for forward compatibility. Current: 12.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -88,6 +89,15 @@ pub struct ObserverSnapshot {
|
||||
/// Empty when no sounds are in range.
|
||||
#[serde(default)]
|
||||
pub sound_events: Vec<crate::simulation::sound::SoundEvent>,
|
||||
/// Overheard NPC-to-NPC conversation lines this tick (#247, D-078).
|
||||
/// Each event carries pre-occluded text — client renders verbatim.
|
||||
/// Empty when no conversations are overheard.
|
||||
#[serde(default)]
|
||||
pub conversation_events: Vec<crate::simulation::conversation::ConversationEvent>,
|
||||
/// Conversations that ended this tick (#247, D-078).
|
||||
/// Client dismisses the passive dialogue panel for these pairs.
|
||||
#[serde(default)]
|
||||
pub conversation_ended: Vec<crate::simulation::conversation::ConversationEndEvent>,
|
||||
/// RNG seed active at this tick for deterministic replay (#527).
|
||||
/// The WRONG button writes this to seed.txt so replays reproduce observed bugs.
|
||||
/// None when the RNG resource is unavailable (should not occur in practice).
|
||||
|
||||
@@ -99,6 +99,10 @@ pub enum Situation {
|
||||
Observation,
|
||||
/// Added Sprint 8 (D-035 amendment): PC dialogue initial contact lines.
|
||||
Greeting,
|
||||
/// First player-NPC interaction — interaction_count == 0 (#325, D-028 Layer 2).
|
||||
FirstMeeting,
|
||||
/// Player has talked to this NPC 3+ times — interaction_count >= 3 (#325, D-028 Layer 2).
|
||||
RepeatedVisit,
|
||||
}
|
||||
|
||||
impl FromStr for Situation {
|
||||
@@ -119,6 +123,8 @@ impl FromStr for Situation {
|
||||
"routine" => Ok(Self::Routine),
|
||||
"observation" => Ok(Self::Observation),
|
||||
"greeting" => Ok(Self::Greeting),
|
||||
"first_meeting" => Ok(Self::FirstMeeting),
|
||||
"repeated_visit" => Ok(Self::RepeatedVisit),
|
||||
_ => Err(ParseEnumError {
|
||||
kind: "Situation",
|
||||
value: s.to_string(),
|
||||
@@ -165,6 +171,7 @@ impl FromStr for Topic {
|
||||
/// D-028 Layer 4: Mood tag — influences weighted selection.
|
||||
///
|
||||
/// 8 v0.1 values aligned to voice guide vocabulary (Sprint 14 rename).
|
||||
/// D-035 amendment (Sprint 8): `Focused` added as 9th variant.
|
||||
/// Neutral mood is represented by omitting the mood tag (untagged = baseline).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Mood {
|
||||
@@ -175,6 +182,8 @@ pub enum Mood {
|
||||
Warm,
|
||||
Hostile,
|
||||
Relieved,
|
||||
/// D-035 amendment (Sprint 8): task-focused NPC mood — used at The Terminal
|
||||
/// and maintenance corridors. Maps from NpcMood::Focused.
|
||||
Focused,
|
||||
}
|
||||
|
||||
@@ -674,6 +683,8 @@ mod tests {
|
||||
"routine",
|
||||
"observation",
|
||||
"greeting", // Sprint 8 amendment (D-035)
|
||||
"first_meeting",
|
||||
"repeated_visit",
|
||||
];
|
||||
for v in values {
|
||||
assert!(
|
||||
|
||||
@@ -111,6 +111,12 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
|
||||
// Mark NPC as interactable for proximity-based verb detection (#413)
|
||||
entity_commands.insert(Interactable);
|
||||
|
||||
// Interaction history — drives Layer 2 situation activation (#325, D-028)
|
||||
entity_commands.insert(npc::interaction::InteractionMemory::default());
|
||||
|
||||
// Mood state — drives Layer 4 dialogue selection and monologue tone (#323)
|
||||
entity_commands.insert(npc::mood::MoodState::default());
|
||||
|
||||
// Axis 1: Want
|
||||
if let Some(want) = &profile.want {
|
||||
if let Some(kind) = parse_want_kind(&want.primary) {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Interaction tracking component — ticket #325.
|
||||
//!
|
||||
//! `InteractionMemory` is a per-NPC component tracking the player's interaction
|
||||
//! history with that NPC. Drives D-028 Layer 2 situation activation:
|
||||
//! - `interaction_count == 0` → `Situation::FirstMeeting`
|
||||
//! - `interaction_count >= 3` → `Situation::RepeatedVisit`
|
||||
//!
|
||||
//! Populated by `process_talk_interaction` in `dialogue.rs` each time a talk
|
||||
//! line is selected. Walk-away and confrontation events appended to
|
||||
//! `notable_events` for fast per-pair access (complements the KnowledgeGraph).
|
||||
//!
|
||||
//! No HashMap. No floats. Deterministic (no random access to notable_events).
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
/// Notable event kinds recorded per player-NPC interaction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InteractionEventKind {
|
||||
/// Player walked away during active dialogue (D-064).
|
||||
WalkAway,
|
||||
/// Player delivered a confrontation (D-063).
|
||||
Confrontation,
|
||||
}
|
||||
|
||||
/// A single notable event in an interaction history.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InteractionEvent {
|
||||
/// Simulation tick the event occurred.
|
||||
pub tick: u64,
|
||||
/// The kind of event.
|
||||
pub kind: InteractionEventKind,
|
||||
}
|
||||
|
||||
/// Per-NPC interaction history with the player (#325, D-028 Layer 2).
|
||||
///
|
||||
/// Spawned on every NPC entity. Drives situation derivation for Layer 2
|
||||
/// dialogue selection: `first_meeting` (count == 0), `repeated_visit`
|
||||
/// (count >= 3). `notable_events` stores walk-aways and confrontations for
|
||||
/// fast lookup without a full KnowledgeGraph query.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct InteractionMemory {
|
||||
/// Total number of completed Talk interactions with the player.
|
||||
/// Incremented each time a dialogue line is selected in `process_talk_interaction`.
|
||||
pub interaction_count: u32,
|
||||
/// Tick of the most recent completed Talk interaction.
|
||||
/// Used for trust decay baseline (D-028 trust progression, #324).
|
||||
pub last_interaction_tick: u64,
|
||||
/// Notable events: walk-aways and confrontations.
|
||||
/// Bounded by `MAX_NOTABLE_EVENTS` — oldest entries dropped when full.
|
||||
pub notable_events: std::collections::VecDeque<InteractionEvent>,
|
||||
}
|
||||
|
||||
/// Maximum number of notable events retained per NPC pair.
|
||||
pub const MAX_NOTABLE_EVENTS: usize = 16;
|
||||
|
||||
impl InteractionMemory {
|
||||
/// Record a completed Talk interaction.
|
||||
///
|
||||
/// Increments `interaction_count` and stamps `last_interaction_tick`.
|
||||
pub fn record_talk(&mut self, tick: u64) {
|
||||
self.interaction_count = self.interaction_count.saturating_add(1);
|
||||
self.last_interaction_tick = tick;
|
||||
}
|
||||
|
||||
/// Append a notable event, dropping the oldest if at capacity.
|
||||
pub fn push_event(&mut self, event: InteractionEvent) {
|
||||
if self.notable_events.len() >= MAX_NOTABLE_EVENTS {
|
||||
self.notable_events.pop_front();
|
||||
}
|
||||
self.notable_events.push_back(event);
|
||||
}
|
||||
|
||||
/// Returns `true` if this is the first meeting (count == 0).
|
||||
pub fn is_first_meeting(&self) -> bool {
|
||||
self.interaction_count == 0
|
||||
}
|
||||
|
||||
/// Returns `true` if this qualifies as a repeated visit (count >= 3).
|
||||
pub fn is_repeated_visit(&self) -> bool {
|
||||
self.interaction_count >= 3
|
||||
}
|
||||
|
||||
/// Count notable events of a given kind.
|
||||
pub fn count_events(&self, kind: InteractionEventKind) -> usize {
|
||||
self.notable_events.iter().filter(|e| e.kind == kind).count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_first_meeting() {
|
||||
let mem = InteractionMemory::default();
|
||||
assert!(mem.is_first_meeting());
|
||||
assert!(!mem.is_repeated_visit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_talk_increments_count() {
|
||||
let mut mem = InteractionMemory::default();
|
||||
mem.record_talk(10);
|
||||
assert_eq!(mem.interaction_count, 1);
|
||||
assert_eq!(mem.last_interaction_tick, 10);
|
||||
assert!(!mem.is_first_meeting());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_visit_threshold_at_three() {
|
||||
let mut mem = InteractionMemory::default();
|
||||
assert!(!mem.is_repeated_visit());
|
||||
mem.record_talk(10);
|
||||
mem.record_talk(20);
|
||||
assert!(!mem.is_repeated_visit());
|
||||
mem.record_talk(30);
|
||||
assert!(mem.is_repeated_visit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_event_appends() {
|
||||
let mut mem = InteractionMemory::default();
|
||||
mem.push_event(InteractionEvent {
|
||||
tick: 5,
|
||||
kind: InteractionEventKind::WalkAway,
|
||||
});
|
||||
assert_eq!(mem.notable_events.len(), 1);
|
||||
assert_eq!(mem.notable_events[0].kind, InteractionEventKind::WalkAway);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_event_drops_oldest_when_full() {
|
||||
let mut mem = InteractionMemory::default();
|
||||
for i in 0..MAX_NOTABLE_EVENTS {
|
||||
mem.push_event(InteractionEvent {
|
||||
tick: i as u64,
|
||||
kind: InteractionEventKind::WalkAway,
|
||||
});
|
||||
}
|
||||
assert_eq!(mem.notable_events.len(), MAX_NOTABLE_EVENTS);
|
||||
// Pushing one more should drop the oldest (tick=0)
|
||||
mem.push_event(InteractionEvent {
|
||||
tick: 99,
|
||||
kind: InteractionEventKind::Confrontation,
|
||||
});
|
||||
assert_eq!(mem.notable_events.len(), MAX_NOTABLE_EVENTS);
|
||||
assert_eq!(mem.notable_events[0].tick, 1); // tick=0 dropped
|
||||
assert_eq!(mem.notable_events.back().unwrap().tick, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_events_filters_by_kind() {
|
||||
let mut mem = InteractionMemory::default();
|
||||
mem.push_event(InteractionEvent {
|
||||
tick: 1,
|
||||
kind: InteractionEventKind::WalkAway,
|
||||
});
|
||||
mem.push_event(InteractionEvent {
|
||||
tick: 2,
|
||||
kind: InteractionEventKind::Confrontation,
|
||||
});
|
||||
mem.push_event(InteractionEvent {
|
||||
tick: 3,
|
||||
kind: InteractionEventKind::WalkAway,
|
||||
});
|
||||
assert_eq!(mem.count_events(InteractionEventKind::WalkAway), 2);
|
||||
assert_eq!(mem.count_events(InteractionEventKind::Confrontation), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_talk_saturates_on_overflow() {
|
||||
let mut mem = InteractionMemory {
|
||||
interaction_count: u32::MAX,
|
||||
..Default::default()
|
||||
};
|
||||
mem.record_talk(1);
|
||||
assert_eq!(mem.interaction_count, u32::MAX); // saturating_add
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -2,6 +2,8 @@
|
||||
// Implements D-024: 10-axis NPC model + CombatCapability component
|
||||
// Background tier state machines for schedule, mood, relationships, job
|
||||
|
||||
pub mod interaction;
|
||||
pub mod mood;
|
||||
pub mod relationships;
|
||||
pub mod routine;
|
||||
|
||||
@@ -21,11 +23,28 @@ pub struct NpcPlugin;
|
||||
impl Plugin for NpcPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<relationships::RelationshipGraph>()
|
||||
.init_resource::<relationships::TrustEventQueue>()
|
||||
.init_resource::<routine::PreviousDayPhase>()
|
||||
.add_systems(
|
||||
Update,
|
||||
routine::check_phase_transition
|
||||
.before(crate::simulation::pathfinding::compute_paths),
|
||||
(
|
||||
routine::check_phase_transition
|
||||
.before(crate::simulation::pathfinding::compute_paths),
|
||||
mood::update_mood
|
||||
.after(routine::check_phase_transition)
|
||||
.before(crate::simulation::dialogue::process_talk_interaction),
|
||||
relationships::update_trust
|
||||
.after(crate::simulation::dialogue::process_talk_interaction)
|
||||
.after(crate::simulation::dialogue::process_walk_away)
|
||||
.after(crate::simulation::dialogue::process_confrontation_response)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
relationships::update_relationship_dynamics
|
||||
.after(relationships::update_trust)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
routine::enter_activity
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
),
|
||||
);
|
||||
|
||||
tracing::debug!("NpcPlugin initialized");
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
//! NPC mood state machine (#323).
|
||||
//!
|
||||
//! Implements the 8-state NPC mood FSM (D-024 MoodState axis, D-035 taxonomy).
|
||||
//! Mood is derived each tick from simulation inputs (stress, time of day,
|
||||
//! recent interactions) and drives Layer 4 dialogue selection and monologue tone.
|
||||
//!
|
||||
//! All state transitions are deterministic — integer arithmetic only (D-010).
|
||||
//! No floats. No HashMap.
|
||||
//!
|
||||
//! ## Integration points
|
||||
//! - `ToleranceThreshold.current_stress` → primary mood driver
|
||||
//! - `SimulationTime.day_phase()` → Evening phase adds Frustrated pressure
|
||||
//! - `InteractionMemory` (Sprint 14, #325) → will set warm_active flag
|
||||
//! - `CurrentMood` (dialogue.rs) → synced each tick for Layer 4 selection
|
||||
//! - Monologue trigger system → reads NpcMood for tone selection (D-016, future)
|
||||
//! - Tell system (#337, deferred to Sprint 15) → reads MoodState
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::content::line_pool::Mood as ContentMood;
|
||||
use crate::npc::interaction::InteractionMemory;
|
||||
use crate::npc::{Npc, ToleranceThreshold};
|
||||
use crate::simulation::dialogue::CurrentMood;
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::{DayPhase, SimulationTime};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NpcMood enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// NPC simulation mood — 8-state FSM (D-024, D-035 converged taxonomy).
|
||||
///
|
||||
/// Driven by `ToleranceThreshold` stress, time of day, and interaction events.
|
||||
/// Maps to `content::line_pool::Mood` for Layer 4 dialogue tag matching.
|
||||
///
|
||||
/// Copy team references this enum when scripting mood conditions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum NpcMood {
|
||||
/// Default state: no notable stressors, no recent positive events.
|
||||
#[default]
|
||||
Neutral,
|
||||
/// Elevated stress approaching threshold — heightened wariness.
|
||||
Anxious,
|
||||
/// Late-shift fatigue or repeated minor irritations.
|
||||
Frustrated,
|
||||
/// Low stress, positive recent context — settled and cooperative.
|
||||
Content,
|
||||
/// Observing unusual or off-script behavior — targeted wariness.
|
||||
/// Not reachable from `derive_mood()` — set externally by observation pipeline.
|
||||
Suspicious,
|
||||
/// Recent positive player interaction within memory window.
|
||||
Warm,
|
||||
/// Stress at or above threshold — confrontational or withdrawn.
|
||||
Hostile,
|
||||
/// Actively engaged in a scheduled activity — task-focused.
|
||||
/// Not reachable from `derive_mood()` — set externally by activity scheduler (#101).
|
||||
Focused,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MoodState component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-NPC mood component — wraps NpcMood for ECS queries.
|
||||
///
|
||||
/// Updated each tick by `update_mood` for Active-tier NPCs.
|
||||
/// Read by: dialogue Layer 4 (via CurrentMood sync), tell system (#337),
|
||||
/// monologue tone selection (D-016, future scope).
|
||||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MoodState {
|
||||
pub mood: NpcMood,
|
||||
/// Tick when mood last changed — guards against thrashing in tests.
|
||||
pub changed_tick: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mood mapping: NpcMood → content::line_pool::Mood
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Map NPC simulation mood to the content dialogue tag.
|
||||
///
|
||||
/// Bridges the simulation FSM (NpcMood) with the dialogue line pool system
|
||||
/// (content::line_pool::Mood). The mapping is intentionally lossy in some
|
||||
/// directions — multiple simulation moods map to the same content tag when
|
||||
/// the distinction matters for behavior but not for line selection.
|
||||
pub fn mood_to_content_mood(mood: NpcMood) -> ContentMood {
|
||||
match mood {
|
||||
NpcMood::Neutral => ContentMood::Comfortable,
|
||||
NpcMood::Anxious => ContentMood::Worried,
|
||||
NpcMood::Frustrated => ContentMood::Conflicted,
|
||||
NpcMood::Content => ContentMood::Relieved,
|
||||
NpcMood::Suspicious => ContentMood::Suspicious,
|
||||
NpcMood::Warm => ContentMood::Fond,
|
||||
NpcMood::Hostile => ContentMood::Concerned,
|
||||
NpcMood::Focused => ContentMood::Focused,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mood derivation (pure, testable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Stress fraction threshold for Anxious: 60% of tolerance threshold.
|
||||
///
|
||||
/// Uses integer multiplication to avoid division:
|
||||
/// Anxious when `current_stress * 100 >= threshold * ANXIOUS_STRESS_NUMERATOR`
|
||||
/// Equivalent to: `current_stress >= threshold * 0.60`
|
||||
const ANXIOUS_STRESS_NUMERATOR: i16 = 60;
|
||||
|
||||
/// Stress level below which an NPC is considered Content (no notable pressure).
|
||||
const CONTENT_STRESS_CEILING: i16 = 20;
|
||||
|
||||
/// Minimum stress for Evening → Frustrated (avoids Frustrated at zero stress).
|
||||
const FRUSTRATED_STRESS_FLOOR: i16 = 10;
|
||||
|
||||
/// Ticks within which a completed Talk interaction keeps the Warm mood active.
|
||||
/// 300 ticks = 30 game-minutes (D-031: 10 ticks/minute).
|
||||
pub const WARM_INTERACTION_WINDOW_TICKS: u64 = 300;
|
||||
|
||||
/// Derive NPC mood from simulation inputs.
|
||||
///
|
||||
/// Priority ordering (high to low):
|
||||
/// 1. Hostile — stress at or above threshold
|
||||
/// 2. Anxious — stress at 60% of threshold or above
|
||||
/// 3. Warm — recent positive player interaction
|
||||
/// 4. Frustrated — Evening phase with non-trivial stress
|
||||
/// 5. Content — very low stress (< CONTENT_STRESS_CEILING)
|
||||
/// 6. Neutral — everything else
|
||||
///
|
||||
/// Inputs are all integer or enum — no floats (D-010 determinism).
|
||||
///
|
||||
/// `warm_active`: set by InteractionMemory (#325, Sprint 14) when a positive
|
||||
/// interaction occurred within the memory window. Placeholder `false` until
|
||||
/// #325 is wired.
|
||||
pub fn derive_mood(
|
||||
current_stress: i16,
|
||||
threshold: i16,
|
||||
phase: DayPhase,
|
||||
warm_active: bool,
|
||||
) -> NpcMood {
|
||||
// 1. Hostile: at or above threshold
|
||||
if current_stress >= threshold {
|
||||
return NpcMood::Hostile;
|
||||
}
|
||||
|
||||
// 2. Anxious: above 60% of threshold.
|
||||
// Guard: skip if threshold == 0 (divide-by-zero equivalent — entity
|
||||
// has no tolerance and is already Hostile from rule 1).
|
||||
if threshold > 0
|
||||
&& (current_stress as i32) * 100 >= (threshold as i32) * (ANXIOUS_STRESS_NUMERATOR as i32)
|
||||
{
|
||||
return NpcMood::Anxious;
|
||||
}
|
||||
|
||||
// 3. Warm: recent positive interaction (priority over Frustrated/Content)
|
||||
if warm_active {
|
||||
return NpcMood::Warm;
|
||||
}
|
||||
|
||||
// 4. Frustrated: Evening phase with non-trivial stress
|
||||
if phase == DayPhase::Evening && current_stress >= FRUSTRATED_STRESS_FLOOR {
|
||||
return NpcMood::Frustrated;
|
||||
}
|
||||
|
||||
// 5. Content: very low stress
|
||||
if current_stress < CONTENT_STRESS_CEILING {
|
||||
return NpcMood::Content;
|
||||
}
|
||||
|
||||
// 6. Neutral: moderate stress, no special conditions
|
||||
NpcMood::Neutral
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System: update_mood
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// System: update NpcMood and sync CurrentMood for Active-tier NPCs.
|
||||
///
|
||||
/// Reads `ToleranceThreshold` stress and `SimulationTime` day phase to derive
|
||||
/// the new mood. Updates `MoodState` when mood changes (records changed_tick).
|
||||
/// Syncs `CurrentMood` (used by dialogue Layer 4) every tick regardless of
|
||||
/// whether MoodState changed.
|
||||
///
|
||||
/// Scoped to `ActiveSim` — Background-tier NPCs retain their last mood state
|
||||
/// (D-026). This is intentional: background NPCs simulate passage of time via
|
||||
/// last-known state, not per-tick derivation.
|
||||
///
|
||||
pub fn update_mood(
|
||||
time: Res<SimulationTime>,
|
||||
mut query: Query<
|
||||
(
|
||||
&mut MoodState,
|
||||
Option<&mut CurrentMood>,
|
||||
Option<&ToleranceThreshold>,
|
||||
Option<&InteractionMemory>,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
) {
|
||||
let phase = time.day_phase();
|
||||
let tick = time.tick;
|
||||
|
||||
for (mut mood_state, current_mood_opt, tolerance_opt, interaction_mem_opt) in query.iter_mut() {
|
||||
let (stress, threshold) = tolerance_opt
|
||||
.map(|t| (t.current_stress, t.threshold))
|
||||
.unwrap_or((0, 50)); // Default: no stress, moderate threshold
|
||||
|
||||
// Warm: recent positive player interaction within memory window (#325)
|
||||
let warm_active = interaction_mem_opt
|
||||
.map(|mem| {
|
||||
mem.interaction_count > 0
|
||||
&& tick.saturating_sub(mem.last_interaction_tick)
|
||||
< WARM_INTERACTION_WINDOW_TICKS
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let new_mood = derive_mood(stress, threshold, phase, warm_active);
|
||||
|
||||
if mood_state.mood != new_mood {
|
||||
mood_state.mood = new_mood;
|
||||
mood_state.changed_tick = tick;
|
||||
}
|
||||
|
||||
// Sync CurrentMood for dialogue pipeline — always, not just on change.
|
||||
// CurrentMood drives Layer 4 scoring; it must reflect current simulation
|
||||
// state even if MoodState itself didn't change this tick.
|
||||
if let Some(mut current_mood) = current_mood_opt {
|
||||
current_mood.0 = mood_to_content_mood(new_mood);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::npc::{Npc, ToleranceThreshold};
|
||||
use crate::simulation::dialogue::CurrentMood;
|
||||
use crate::simulation::tier::{ActiveSim, BackgroundSim};
|
||||
use crate::simulation::time::{DayPhase, SimulationTime};
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
// --- derive_mood unit tests ---
|
||||
|
||||
#[test]
|
||||
fn mood_hostile_when_stress_equals_threshold() {
|
||||
assert_eq!(
|
||||
derive_mood(50, 50, DayPhase::Morning, false),
|
||||
NpcMood::Hostile
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_hostile_when_stress_above_threshold() {
|
||||
assert_eq!(
|
||||
derive_mood(80, 50, DayPhase::Morning, false),
|
||||
NpcMood::Hostile
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_anxious_at_60_percent_threshold() {
|
||||
// 60% of threshold=100 is 60. stress=60 → Anxious.
|
||||
assert_eq!(
|
||||
derive_mood(60, 100, DayPhase::Morning, false),
|
||||
NpcMood::Anxious
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_anxious_boundary_above() {
|
||||
// threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000).
|
||||
assert_eq!(
|
||||
derive_mood(30, 50, DayPhase::Morning, false),
|
||||
NpcMood::Anxious
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_not_anxious_just_below_boundary() {
|
||||
// threshold=50: 60% = 30. stress=29 → not Anxious (29*100=2900 < 3000).
|
||||
// stress=29 < 20 is false, so → Neutral.
|
||||
assert_eq!(
|
||||
derive_mood(29, 50, DayPhase::Morning, false),
|
||||
NpcMood::Neutral
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_warm_when_positive_interaction() {
|
||||
assert_eq!(
|
||||
derive_mood(0, 50, DayPhase::Morning, true),
|
||||
NpcMood::Warm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_frustrated_when_evening_with_stress() {
|
||||
// stress=25 (not hostile/anxious), Evening phase → Frustrated
|
||||
assert_eq!(
|
||||
derive_mood(25, 50, DayPhase::Evening, false),
|
||||
NpcMood::Frustrated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_not_frustrated_in_morning() {
|
||||
assert_eq!(
|
||||
derive_mood(25, 50, DayPhase::Morning, false),
|
||||
NpcMood::Neutral
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_not_frustrated_when_stress_below_floor() {
|
||||
// stress=5 < FRUSTRATED_STRESS_FLOOR=10 → Content (stress < 20)
|
||||
assert_eq!(
|
||||
derive_mood(5, 50, DayPhase::Evening, false),
|
||||
NpcMood::Content
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_content_when_low_stress() {
|
||||
assert_eq!(
|
||||
derive_mood(15, 50, DayPhase::Morning, false),
|
||||
NpcMood::Content
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_content_boundary_at_19() {
|
||||
// stress=19 < CONTENT_STRESS_CEILING=20 → Content
|
||||
assert_eq!(
|
||||
derive_mood(19, 50, DayPhase::Morning, false),
|
||||
NpcMood::Content
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_neutral_otherwise() {
|
||||
// stress=25, not anxious (25*100=2500 < 50*60=3000), not Warm, morning, not Content
|
||||
// Wait: 25*100=2500, 50*60=3000 → not Anxious. 25 >= 20 → not Content. Morning → not Frustrated. → Neutral
|
||||
assert_eq!(
|
||||
derive_mood(25, 50, DayPhase::Morning, false),
|
||||
NpcMood::Neutral
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_priority_hostile_over_anxious_at_threshold() {
|
||||
// At exactly threshold → Hostile, not Anxious
|
||||
assert_eq!(
|
||||
derive_mood(50, 50, DayPhase::Morning, false),
|
||||
NpcMood::Hostile
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_priority_hostile_over_frustrated_evening() {
|
||||
assert_eq!(
|
||||
derive_mood(50, 50, DayPhase::Evening, false),
|
||||
NpcMood::Hostile
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_priority_anxious_over_warm() {
|
||||
// Anxious takes priority over Warm interaction
|
||||
assert_eq!(
|
||||
derive_mood(60, 100, DayPhase::Morning, true),
|
||||
NpcMood::Anxious
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_priority_warm_over_frustrated() {
|
||||
// Warm takes priority over Frustrated (checked before Evening test)
|
||||
assert_eq!(
|
||||
derive_mood(25, 50, DayPhase::Evening, true),
|
||||
NpcMood::Warm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_zero_threshold_is_hostile() {
|
||||
// stress=0, threshold=0: 0 >= 0 → Hostile
|
||||
assert_eq!(
|
||||
derive_mood(0, 0, DayPhase::Morning, false),
|
||||
NpcMood::Hostile
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_content_zero_stress_moderate_threshold() {
|
||||
// stress=0, threshold=50: not hostile, not anxious (threshold > 0, 0*100=0 < 50*60=3000),
|
||||
// not warm, not evening, stress < 20 → Content
|
||||
assert_eq!(
|
||||
derive_mood(0, 50, DayPhase::Morning, false),
|
||||
NpcMood::Content
|
||||
);
|
||||
}
|
||||
|
||||
// --- mood_to_content_mood mapping coverage ---
|
||||
|
||||
#[test]
|
||||
fn mood_mapping_covers_all_variants() {
|
||||
for mood in [
|
||||
NpcMood::Neutral,
|
||||
NpcMood::Anxious,
|
||||
NpcMood::Frustrated,
|
||||
NpcMood::Content,
|
||||
NpcMood::Suspicious,
|
||||
NpcMood::Warm,
|
||||
NpcMood::Hostile,
|
||||
NpcMood::Focused,
|
||||
] {
|
||||
let _ = mood_to_content_mood(mood); // must not panic
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_mapping_anxious_is_worried() {
|
||||
assert_eq!(mood_to_content_mood(NpcMood::Anxious), ContentMood::Worried);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_mapping_warm_is_fond() {
|
||||
assert_eq!(mood_to_content_mood(NpcMood::Warm), ContentMood::Fond);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_mapping_suspicious_is_suspicious() {
|
||||
assert_eq!(
|
||||
mood_to_content_mood(NpcMood::Suspicious),
|
||||
ContentMood::Suspicious
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mood_mapping_focused_is_focused() {
|
||||
assert_eq!(mood_to_content_mood(NpcMood::Focused), ContentMood::Focused);
|
||||
}
|
||||
|
||||
// --- update_mood system integration tests ---
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_mood_sets_hostile_when_stress_at_threshold() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MoodState::default(),
|
||||
CurrentMood::default(),
|
||||
ToleranceThreshold {
|
||||
current_stress: 50,
|
||||
threshold: 50,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_mood);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mood_state = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood_state.mood, NpcMood::Hostile);
|
||||
|
||||
let current_mood = world.get::<CurrentMood>(npc).unwrap();
|
||||
assert_eq!(current_mood.0, ContentMood::Concerned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_mood_defaults_to_content_without_tolerance() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// No ToleranceThreshold → defaults (stress=0, threshold=50) → Content
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MoodState::default(),
|
||||
CurrentMood::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_mood);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mood_state = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood_state.mood, NpcMood::Content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_mood_records_changed_tick_on_transition() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 42;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
// Start Warm, will transition to Hostile
|
||||
MoodState {
|
||||
mood: NpcMood::Warm,
|
||||
changed_tick: 0,
|
||||
},
|
||||
CurrentMood::default(),
|
||||
ToleranceThreshold {
|
||||
current_stress: 50,
|
||||
threshold: 50,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_mood);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mood_state = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood_state.mood, NpcMood::Hostile);
|
||||
assert_eq!(mood_state.changed_tick, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_mood_does_not_update_changed_tick_when_unchanged() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 42;
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
// Already Content; no tolerance → will derive Content again
|
||||
MoodState {
|
||||
mood: NpcMood::Content,
|
||||
changed_tick: 5,
|
||||
},
|
||||
CurrentMood::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_mood);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mood_state = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood_state.mood, NpcMood::Content);
|
||||
assert_eq!(mood_state.changed_tick, 5); // unchanged
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_mood_skips_background_npcs() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// BackgroundSim NPC — must not be updated
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
BackgroundSim,
|
||||
MoodState {
|
||||
mood: NpcMood::Warm,
|
||||
changed_tick: 0,
|
||||
},
|
||||
CurrentMood::default(),
|
||||
ToleranceThreshold {
|
||||
current_stress: 50,
|
||||
threshold: 50, // Would → Hostile if processed
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_mood);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mood_state = world.get::<MoodState>(npc).unwrap();
|
||||
// Must remain Warm — not processed because BackgroundSim, not ActiveSim
|
||||
assert_eq!(mood_state.mood, NpcMood::Warm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_mood_syncs_current_mood_when_present() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MoodState::default(),
|
||||
CurrentMood::default(), // Starts at Comfortable
|
||||
ToleranceThreshold {
|
||||
current_stress: 70,
|
||||
threshold: 100, // → Anxious (70% of 100)
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_mood);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let current_mood = world.get::<CurrentMood>(npc).unwrap();
|
||||
// Anxious maps to Worried
|
||||
assert_eq!(current_mood.0, ContentMood::Worried);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_mood_works_without_current_mood() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC without CurrentMood — system must not panic
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MoodState::default(),
|
||||
// No CurrentMood
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_mood);
|
||||
schedule.run(&mut world); // must not panic
|
||||
|
||||
let mood_state = world.get::<MoodState>(npc).unwrap();
|
||||
assert_eq!(mood_state.mood, NpcMood::Content);
|
||||
}
|
||||
|
||||
// -- Additional QA coverage (Hoshe, Sprint 14) --------------------------
|
||||
|
||||
#[test]
|
||||
fn derive_mood_negative_stress_is_content() {
|
||||
// i16 stress can be negative (e.g. buffs reducing stress below zero).
|
||||
// Negative stress is well below CONTENT_STRESS_CEILING (20) → Content.
|
||||
// Note: `current_stress * 100` in the Anxious check can overflow i16 for extreme
|
||||
// values (stress < -327 or > 327 at threshold=50). Realistic game values stay small.
|
||||
assert_eq!(
|
||||
derive_mood(-10, 50, DayPhase::Morning, false),
|
||||
NpcMood::Content,
|
||||
"Negative stress not hostile/anxious, morning, stress<20 → Content"
|
||||
);
|
||||
assert_eq!(
|
||||
derive_mood(-50, 50, DayPhase::Evening, false),
|
||||
NpcMood::Content,
|
||||
"Negative stress in Evening: stress < FRUSTRATED_STRESS_FLOOR (10) → Content not Frustrated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_mood_cannot_return_suspicious_or_focused() {
|
||||
// Suspicious and Focused are valid NpcMood states but are NOT reachable
|
||||
// from derive_mood(). They must be set externally by other systems
|
||||
// (e.g., observation pipeline for Suspicious, activity scheduler for Focused).
|
||||
// This test documents the invariant: derive_mood never emits these states.
|
||||
use std::collections::HashSet;
|
||||
|
||||
let phases = [DayPhase::Morning, DayPhase::Afternoon, DayPhase::Evening, DayPhase::Night];
|
||||
let stresses: &[i16] = &[-50, -1, 0, 1, 19, 20, 29, 30, 49, 50, 51, 100];
|
||||
let thresholds: &[i16] = &[0, 1, 50, 100];
|
||||
let warm_flags = [false, true];
|
||||
|
||||
let mut observed = HashSet::new();
|
||||
for &phase in &phases {
|
||||
for &stress in stresses {
|
||||
for &threshold in thresholds {
|
||||
for warm in warm_flags {
|
||||
let m = derive_mood(stress, threshold, phase, warm);
|
||||
observed.insert(format!("{:?}", m));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!observed.contains("Suspicious"),
|
||||
"derive_mood should never return Suspicious — set by observation pipeline"
|
||||
);
|
||||
assert!(
|
||||
!observed.contains("Focused"),
|
||||
"derive_mood should never return Focused — set by activity scheduler (#101)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_mood_multiple_npcs_independent() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let calm = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MoodState::default(),
|
||||
CurrentMood::default(),
|
||||
ToleranceThreshold {
|
||||
current_stress: 5,
|
||||
threshold: 50,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let stressed = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
MoodState::default(),
|
||||
CurrentMood::default(),
|
||||
ToleranceThreshold {
|
||||
current_stress: 50,
|
||||
threshold: 50,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_mood);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(world.get::<MoodState>(calm).unwrap().mood, NpcMood::Content);
|
||||
assert_eq!(
|
||||
world.get::<MoodState>(stressed).unwrap().mood,
|
||||
NpcMood::Hostile
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,90 @@
|
||||
//! Global relationship graph resource (D-024).
|
||||
//! Global relationship graph resource (D-024) and trust progression (#324).
|
||||
//!
|
||||
//! Tracks how entities feel about each other. Separate from KnowledgeGraph
|
||||
//! (what entities know) — this is what entities feel.
|
||||
//! BTreeMap with tuple key (subject, target) for deterministic iteration
|
||||
//! and efficient prefix queries via range().
|
||||
//!
|
||||
//! Trust progression: interaction events (talk, walk-away, confrontation)
|
||||
//! adjust the per-edge `trust: i8` value via the `update_trust` system.
|
||||
//! Trust maps to D-028 TrustTier via `relationship_to_trust()` in dialogue.rs.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
use super::{RelationshipEvent, RelationshipKind};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trust event types (#324)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Trust delta for a completed Talk interaction: NPC warms to the player.
|
||||
pub const TALK_TRUST_DELTA: i8 = 1;
|
||||
|
||||
/// Trust delta when the player walks away mid-dialogue: NPC feels slighted.
|
||||
pub const WALK_AWAY_TRUST_DELTA: i8 = -1;
|
||||
|
||||
/// Trust delta when the player delivers a confrontation: NPC feels threatened.
|
||||
pub const CONFRONTATION_TRUST_DELTA: i8 = -2;
|
||||
|
||||
/// Events that modify trust on the RelationshipGraph.
|
||||
///
|
||||
/// Produced by dialogue systems, consumed by `update_trust` each tick.
|
||||
/// Direction: always (NPC → player), tracking how the NPC feels about
|
||||
/// the player after an interaction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TrustEvent {
|
||||
/// Player completed a Talk exchange with an NPC.
|
||||
TalkCompleted {
|
||||
npc: Entity,
|
||||
player: Entity,
|
||||
},
|
||||
/// Player walked away during active dialogue (D-064).
|
||||
WalkAway {
|
||||
npc: Entity,
|
||||
player: Entity,
|
||||
},
|
||||
/// Player delivered a confrontation (D-063).
|
||||
ConfrontationDelivered {
|
||||
npc: Entity,
|
||||
player: Entity,
|
||||
},
|
||||
}
|
||||
|
||||
/// Resource: queue of pending trust events.
|
||||
/// Drained once per tick by the `update_trust` system.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct TrustEventQueue {
|
||||
events: Vec<TrustEvent>,
|
||||
}
|
||||
|
||||
impl TrustEventQueue {
|
||||
/// Push a trust event into the queue.
|
||||
pub fn push(&mut self, event: TrustEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
/// Drain all pending events.
|
||||
pub fn drain(&mut self) -> Vec<TrustEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
/// Number of pending events.
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
/// Whether the queue is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge in the relationship graph. Directed: A's feelings about B.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelationshipEdge {
|
||||
@@ -69,8 +141,8 @@ impl RelationshipGraph {
|
||||
}
|
||||
|
||||
/// Get all entities who have feelings about a target.
|
||||
/// Full scan — use for event detection, not per-tick queries.
|
||||
pub fn who_knows(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
|
||||
/// O(N) full scan of all edges — use for event detection, not per-tick queries.
|
||||
pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
|
||||
self.edges
|
||||
.iter()
|
||||
.filter(|((_, t), _)| t == target)
|
||||
@@ -98,6 +170,137 @@ impl RelationshipGraph {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.edges.is_empty()
|
||||
}
|
||||
|
||||
/// Iterate over all edges mutably (for decay system).
|
||||
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut RelationshipEdge> {
|
||||
self.edges.values_mut()
|
||||
}
|
||||
|
||||
/// Get or create an edge between subject and target.
|
||||
///
|
||||
/// If no edge exists, inserts a default Colleague edge with trust 0.
|
||||
/// Returns a mutable reference for direct field modification.
|
||||
pub fn ensure_edge(
|
||||
&mut self,
|
||||
subject: StableId,
|
||||
target: StableId,
|
||||
tick: u64,
|
||||
) -> &mut RelationshipEdge {
|
||||
self.edges
|
||||
.entry((subject, target))
|
||||
.or_insert_with(|| RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 0,
|
||||
history: vec![],
|
||||
last_interaction_tick: tick,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System: update_trust (#324)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Drain pending trust events and apply deltas to the RelationshipGraph.
|
||||
///
|
||||
/// Each event adjusts the NPC→player trust edge. If no edge exists,
|
||||
/// one is created with default Colleague kind and trust 0 before applying
|
||||
/// the delta. Trust is clamped to [-10, +10] per D-010.
|
||||
///
|
||||
/// System ordering: after dialogue systems (which emit the events),
|
||||
/// before advance_tick.
|
||||
pub fn update_trust(
|
||||
mut queue: ResMut<TrustEventQueue>,
|
||||
mut graph: ResMut<RelationshipGraph>,
|
||||
registry: Res<EntityRegistry>,
|
||||
time: Res<SimulationTime>,
|
||||
) {
|
||||
for event in queue.drain() {
|
||||
let (npc, player, delta) = match event {
|
||||
TrustEvent::TalkCompleted { npc, player } => (npc, player, TALK_TRUST_DELTA),
|
||||
TrustEvent::WalkAway { npc, player } => (npc, player, WALK_AWAY_TRUST_DELTA),
|
||||
TrustEvent::ConfrontationDelivered { npc, player } => {
|
||||
(npc, player, CONFRONTATION_TRUST_DELTA)
|
||||
}
|
||||
};
|
||||
|
||||
let Some(npc_sid) = registry.to_stable(npc) else {
|
||||
tracing::warn!("Trust event for unregistered NPC {:?}", npc);
|
||||
continue;
|
||||
};
|
||||
let Some(player_sid) = registry.to_stable(player) else {
|
||||
tracing::warn!("Trust event for unregistered player {:?}", player);
|
||||
continue;
|
||||
};
|
||||
|
||||
let edge = graph.ensure_edge(npc_sid, player_sid, time.tick);
|
||||
edge.trust = edge.trust.saturating_add(delta).clamp(-10, 10);
|
||||
edge.last_interaction_tick = time.tick;
|
||||
|
||||
tracing::debug!(
|
||||
npc = npc_sid.0,
|
||||
player = player_sid.0,
|
||||
delta,
|
||||
new_trust = edge.trust,
|
||||
"Trust updated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System: update_relationship_dynamics (#103)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ticks between decay evaluations — 1 game-minute (D-031: 10 ticks/minute).
|
||||
const DECAY_INTERVAL_TICKS: u64 = 10;
|
||||
|
||||
/// Ticks without interaction before trust decay begins — 1 game-hour
|
||||
/// (10 ticks/minute × 60 minutes = 600 ticks).
|
||||
const DECAY_INACTIVITY_THRESHOLD_TICKS: u64 = 600;
|
||||
|
||||
/// Passive trust decay applied per decay interval.
|
||||
/// Trust drifts toward 0 at 1 point per hour of inactivity.
|
||||
const DECAY_DELTA: i8 = 1;
|
||||
|
||||
/// Apply passive trust decay to NPC-NPC relationships (#103, D-024).
|
||||
///
|
||||
/// Runs once per game-minute (every 10 ticks). For each relationship edge
|
||||
/// inactive for more than one game-hour, decays trust 1 point toward 0.
|
||||
/// Positive trust decreases; negative trust increases; zero trust is stable.
|
||||
///
|
||||
/// This creates the social texture over time: NPCs who haven't interacted
|
||||
/// recently drift back to neutral, making active relationship maintenance
|
||||
/// meaningful. Blocks #249 (player-action social propagation, Sprint 15).
|
||||
///
|
||||
/// System ordering: after update_trust, before advance_tick.
|
||||
pub fn update_relationship_dynamics(time: Res<SimulationTime>, mut graph: ResMut<RelationshipGraph>) {
|
||||
// Lightweight: evaluate once per game-minute
|
||||
if time.tick % DECAY_INTERVAL_TICKS != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
for edge in graph.values_mut() {
|
||||
let ticks_since = time.tick.saturating_sub(edge.last_interaction_tick);
|
||||
if ticks_since < DECAY_INACTIVITY_THRESHOLD_TICKS {
|
||||
continue; // Recent interaction — no decay
|
||||
}
|
||||
|
||||
let old_trust = edge.trust;
|
||||
edge.trust = match edge.trust.cmp(&0) {
|
||||
std::cmp::Ordering::Greater => (edge.trust - DECAY_DELTA).max(0),
|
||||
std::cmp::Ordering::Less => (edge.trust + DECAY_DELTA).min(0),
|
||||
std::cmp::Ordering::Equal => 0,
|
||||
};
|
||||
|
||||
if edge.trust != old_trust {
|
||||
tracing::trace!(
|
||||
old_trust,
|
||||
new_trust = edge.trust,
|
||||
ticks_inactive = ticks_since,
|
||||
"NPC relationship trust decayed toward neutral"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -171,7 +374,7 @@ mod tests {
|
||||
make_edge(RelationshipKind::Family, 8),
|
||||
);
|
||||
|
||||
let knowers = graph.who_knows(&target);
|
||||
let knowers = graph.who_knows_full_scan(&target);
|
||||
assert_eq!(knowers.len(), 3);
|
||||
}
|
||||
|
||||
@@ -230,4 +433,339 @@ mod tests {
|
||||
assert_eq!(*keys[1], (StableId(2), StableId(3)));
|
||||
assert_eq!(*keys[2], (StableId(3), StableId(1)));
|
||||
}
|
||||
|
||||
// -- ensure_edge tests (#324) -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ensure_edge_creates_default_when_missing() {
|
||||
let mut graph = RelationshipGraph::new();
|
||||
let a = StableId(1);
|
||||
let b = StableId(2);
|
||||
|
||||
let edge = graph.ensure_edge(a, b, 100);
|
||||
assert_eq!(edge.kind, RelationshipKind::Colleague);
|
||||
assert_eq!(edge.trust, 0);
|
||||
assert_eq!(edge.last_interaction_tick, 100);
|
||||
assert_eq!(graph.edge_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_edge_returns_existing_edge() {
|
||||
let mut graph = RelationshipGraph::new();
|
||||
let a = StableId(1);
|
||||
let b = StableId(2);
|
||||
graph.set_relationship(a, b, make_edge(RelationshipKind::Friend, 7));
|
||||
|
||||
let edge = graph.ensure_edge(a, b, 200);
|
||||
// Should return existing edge, not overwrite
|
||||
assert_eq!(edge.kind, RelationshipKind::Friend);
|
||||
assert_eq!(edge.trust, 7);
|
||||
assert_eq!(graph.edge_count(), 1);
|
||||
}
|
||||
|
||||
// -- TrustEventQueue tests (#324) ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn trust_queue_push_and_drain() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
let e2 = world.spawn_empty().id();
|
||||
|
||||
let mut queue = TrustEventQueue::default();
|
||||
assert!(queue.is_empty());
|
||||
|
||||
queue.push(TrustEvent::TalkCompleted {
|
||||
npc: e1,
|
||||
player: e2,
|
||||
});
|
||||
assert_eq!(queue.len(), 1);
|
||||
|
||||
let events = queue.drain();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(queue.is_empty());
|
||||
}
|
||||
|
||||
// -- update_trust system tests (#324) ------------------------------------
|
||||
|
||||
fn setup_trust_world() -> bevy_ecs::world::World {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<RelationshipGraph>();
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn talk_completed_increments_trust() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::TalkCompleted { npc, player });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
let npc_sid = registry.to_stable(npc).unwrap();
|
||||
let player_sid = registry.to_stable(player).unwrap();
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
|
||||
assert_eq!(edge.trust, TALK_TRUST_DELTA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_decrements_trust() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::WalkAway { npc, player });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
let npc_sid = registry.to_stable(npc).unwrap();
|
||||
let player_sid = registry.to_stable(player).unwrap();
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
|
||||
assert_eq!(edge.trust, WALK_AWAY_TRUST_DELTA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confrontation_decrements_trust_more() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::ConfrontationDelivered { npc, player });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
let npc_sid = registry.to_stable(npc).unwrap();
|
||||
let player_sid = registry.to_stable(player).unwrap();
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
|
||||
assert_eq!(edge.trust, CONFRONTATION_TRUST_DELTA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_talks_accumulate_trust() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Push 5 talk events
|
||||
for _ in 0..5 {
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::TalkCompleted { npc, player });
|
||||
}
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
let npc_sid = registry.to_stable(npc).unwrap();
|
||||
let player_sid = registry.to_stable(player).unwrap();
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
|
||||
assert_eq!(edge.trust, 5); // 5 * TALK_TRUST_DELTA(1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_clamps_at_positive_ten() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Push 15 talk events — should clamp at 10
|
||||
for _ in 0..15 {
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::TalkCompleted { npc, player });
|
||||
}
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
let npc_sid = registry.to_stable(npc).unwrap();
|
||||
let player_sid = registry.to_stable(player).unwrap();
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
|
||||
assert_eq!(edge.trust, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_clamps_at_negative_ten() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Push 8 confrontation events — 8 * -2 = -16, should clamp at -10
|
||||
for _ in 0..8 {
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::ConfrontationDelivered { npc, player });
|
||||
}
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
let npc_sid = registry.to_stable(npc).unwrap();
|
||||
let player_sid = registry.to_stable(player).unwrap();
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
|
||||
assert_eq!(edge.trust, -10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_events_net_correctly() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// 3 talks (+3) then 1 walk-away (-1) then 1 confrontation (-2) = net 0
|
||||
let mut queue = world.resource_mut::<TrustEventQueue>();
|
||||
queue.push(TrustEvent::TalkCompleted { npc, player });
|
||||
queue.push(TrustEvent::TalkCompleted { npc, player });
|
||||
queue.push(TrustEvent::TalkCompleted { npc, player });
|
||||
queue.push(TrustEvent::WalkAway { npc, player });
|
||||
queue.push(TrustEvent::ConfrontationDelivered { npc, player });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
let npc_sid = registry.to_stable(npc).unwrap();
|
||||
let player_sid = registry.to_stable(player).unwrap();
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
|
||||
assert_eq!(edge.trust, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_trust_updates_last_interaction_tick() {
|
||||
let mut world = setup_trust_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 42;
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::TalkCompleted { npc, player });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let registry = world.resource::<EntityRegistry>();
|
||||
let npc_sid = registry.to_stable(npc).unwrap();
|
||||
let player_sid = registry.to_stable(player).unwrap();
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
|
||||
assert_eq!(edge.last_interaction_tick, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_trust_preserves_existing_edge_kind() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Pre-populate with a Friend edge at trust 5
|
||||
world.resource_mut::<RelationshipGraph>().set_relationship(
|
||||
npc_sid,
|
||||
player_sid,
|
||||
make_edge(RelationshipKind::Friend, 5),
|
||||
);
|
||||
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::TalkCompleted { npc, player });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
let edge = graph.get_relationship(&npc_sid, &player_sid).unwrap();
|
||||
assert_eq!(edge.kind, RelationshipKind::Friend); // Kind preserved
|
||||
assert_eq!(edge.trust, 6); // 5 + 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_entity_event_is_skipped() {
|
||||
let mut world = setup_trust_world();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let player = world.spawn_empty().id();
|
||||
// Only register npc, not player
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
world
|
||||
.resource_mut::<TrustEventQueue>()
|
||||
.push(TrustEvent::TalkCompleted { npc, player });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(update_trust);
|
||||
schedule.run(&mut world); // Should not panic
|
||||
|
||||
let graph = world.resource::<RelationshipGraph>();
|
||||
assert!(graph.is_empty(), "no edge should be created for unregistered entity");
|
||||
}
|
||||
}
|
||||
|
||||
+417
-3
@@ -1,16 +1,43 @@
|
||||
//! Daily routine system (#88).
|
||||
//! Daily routine system (#88, #101).
|
||||
//!
|
||||
//! Detects day-phase transitions (D-031) and issues PathRequests for NPCs
|
||||
//! whose DailyRoutine has a location for the new phase.
|
||||
//! whose DailyRoutine has a location for the new phase. Tracks NPC activity
|
||||
//! state when they arrive at their routine destination (#101).
|
||||
//!
|
||||
//! Pipeline: phase transition → PathRequest → pathfinder → path_follow →
|
||||
//! NPC arrives → enter_activity sets ActivityState.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::npc::{DailyRoutine, Npc};
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::pathfinding::PathRequest;
|
||||
use crate::simulation::pathfinding::{ComputedPath, PathRequest};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
use crate::simulation::time::{DayPhase, SimulationTime};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ActivityState component (#101)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tracks the activity an NPC is currently performing at their routine location.
|
||||
///
|
||||
/// Set by `enter_activity` when an NPC:
|
||||
/// 1. Has no active `ComputedPath` or `PathRequest` (finished walking)
|
||||
/// 2. Is at the location specified by their `DailyRoutine` for the current phase
|
||||
///
|
||||
/// Cleared on phase transitions (replaced with new activity or removed).
|
||||
/// Feeds `TellTrigger::DuringActivity` and D-028 Layer 2 situation matching.
|
||||
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActivityState {
|
||||
/// Activity name from `RoutineEntry.activity` (e.g., "Work", "Bar", "Sleep").
|
||||
pub activity: String,
|
||||
/// The day phase this activity belongs to.
|
||||
pub phase: DayPhase,
|
||||
/// Tick when the NPC arrived and started this activity.
|
||||
pub started_tick: u64,
|
||||
}
|
||||
|
||||
/// Resource tracking the previous day phase for transition detection.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct PreviousDayPhase {
|
||||
@@ -57,6 +84,9 @@ pub fn check_phase_transition(
|
||||
previous.day = current_day;
|
||||
|
||||
for (entity, current_pos, routine) in npcs.iter() {
|
||||
// Clear stale activity on phase transition — will be re-evaluated by enter_activity
|
||||
commands.entity(entity).remove::<ActivityState>();
|
||||
|
||||
if let Some(expected_location) = routine.expected_location(current_phase) {
|
||||
if *current_pos != expected_location {
|
||||
commands.entity(entity).insert(PathRequest {
|
||||
@@ -73,6 +103,76 @@ pub fn check_phase_transition(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System: enter_activity (#101)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Set ActivityState when an NPC has arrived at their routine destination.
|
||||
///
|
||||
/// Runs after movement validation. Checks NPCs that:
|
||||
/// - Have a DailyRoutine and ActiveSim tier
|
||||
/// - Are NOT currently pathfinding (no ComputedPath or PathRequest)
|
||||
/// - Are at the location specified for the current day phase
|
||||
/// - Don't already have the correct ActivityState for the current phase
|
||||
///
|
||||
/// When conditions are met, inserts an ActivityState component. When an NPC
|
||||
/// has a stale activity from a previous phase and isn't at the new phase's
|
||||
/// destination, the stale activity is removed.
|
||||
///
|
||||
/// System ordering: after validate_movement, before compute_observer_snapshot.
|
||||
pub fn enter_activity(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
npcs: Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
&DailyRoutine,
|
||||
Option<&ActivityState>,
|
||||
),
|
||||
(
|
||||
With<Npc>,
|
||||
With<ActiveSim>,
|
||||
Without<ComputedPath>,
|
||||
Without<PathRequest>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
let current_phase = time.day_phase();
|
||||
|
||||
for (entity, pos, routine, activity_opt) in npcs.iter() {
|
||||
// Already performing the correct activity for this phase
|
||||
if let Some(activity) = activity_opt {
|
||||
if activity.phase == current_phase {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at routine destination for current phase
|
||||
if let Some(entry) = routine.entry_for_phase(current_phase) {
|
||||
if *pos == entry.location {
|
||||
commands.entity(entity).insert(ActivityState {
|
||||
activity: entry.activity.clone(),
|
||||
phase: current_phase,
|
||||
started_tick: time.tick,
|
||||
});
|
||||
tracing::trace!(
|
||||
"Entity {:?}: entered activity '{}' for {:?}",
|
||||
entity,
|
||||
entry.activity,
|
||||
current_phase,
|
||||
);
|
||||
} else {
|
||||
// Not at destination yet — remove stale activity
|
||||
commands.entity(entity).remove::<ActivityState>();
|
||||
}
|
||||
} else {
|
||||
// No routine entry for this phase — remove stale activity
|
||||
commands.entity(entity).remove::<ActivityState>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -243,4 +343,318 @@ mod tests {
|
||||
let request = world.get::<PathRequest>(entity).unwrap();
|
||||
assert_eq!(request.goal, morning_loc);
|
||||
}
|
||||
|
||||
// -- enter_activity tests (#101) ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn npc_at_routine_destination_gets_activity_state() {
|
||||
let mut world = setup_world();
|
||||
// Time = Afternoon
|
||||
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let loc = TilePosition::new(10, 10, 0);
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
loc, // Already at afternoon destination
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: loc,
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(enter_activity);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let state = world.get::<ActivityState>(entity).unwrap();
|
||||
assert_eq!(state.activity, "Work");
|
||||
assert_eq!(state.phase, DayPhase::Afternoon);
|
||||
assert_eq!(state.started_tick, MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_not_at_destination_no_activity_state() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
TilePosition::new(5, 5, 0), // NOT at afternoon location
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(10, 10, 0),
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(enter_activity);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(world.get::<ActivityState>(entity).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_with_computed_path_excluded() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let loc = TilePosition::new(10, 10, 0);
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
loc, // At destination but still has a path
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: loc,
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
ComputedPath {
|
||||
steps: vec![],
|
||||
current_index: 0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(enter_activity);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<ActivityState>(entity).is_none(),
|
||||
"NPC with ComputedPath should not get ActivityState"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_with_path_request_excluded() {
|
||||
let mut world = setup_world();
|
||||
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
|
||||
let loc = TilePosition::new(10, 10, 0);
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
loc,
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: loc,
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
PathRequest { goal: loc },
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(enter_activity);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<ActivityState>(entity).is_none(),
|
||||
"NPC with PathRequest should not get ActivityState"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_activity_same_phase_not_overwritten() {
|
||||
let mut world = setup_world();
|
||||
let tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
world.resource_mut::<SimulationTime>().tick = tick + 100;
|
||||
|
||||
let loc = TilePosition::new(10, 10, 0);
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
loc,
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: loc,
|
||||
activity: "Work".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
ActivityState {
|
||||
activity: "Work".into(),
|
||||
phase: DayPhase::Afternoon,
|
||||
started_tick: tick, // Set earlier
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(enter_activity);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let state = world.get::<ActivityState>(entity).unwrap();
|
||||
assert_eq!(
|
||||
state.started_tick, tick,
|
||||
"started_tick should be preserved, not updated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_activity_replaced_on_phase_change() {
|
||||
let mut world = setup_world();
|
||||
// Time = Evening (after Afternoon)
|
||||
let evening_tick = 2 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
world.resource_mut::<SimulationTime>().tick = evening_tick;
|
||||
|
||||
let evening_loc = TilePosition::new(20, 20, 0);
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
evening_loc, // Already at evening location
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(10, 10, 0),
|
||||
activity: "Work".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Evening,
|
||||
location: evening_loc,
|
||||
activity: "Bar".into(),
|
||||
},
|
||||
],
|
||||
description: "Test".into(),
|
||||
},
|
||||
// Stale activity from previous phase
|
||||
ActivityState {
|
||||
activity: "Work".into(),
|
||||
phase: DayPhase::Afternoon,
|
||||
started_tick: 1000,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(enter_activity);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let state = world.get::<ActivityState>(entity).unwrap();
|
||||
assert_eq!(state.activity, "Bar");
|
||||
assert_eq!(state.phase, DayPhase::Evening);
|
||||
assert_eq!(state.started_tick, evening_tick);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_routine_for_phase_clears_stale_activity() {
|
||||
let mut world = setup_world();
|
||||
// Time = Night
|
||||
let night_tick = 3 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
|
||||
world.resource_mut::<SimulationTime>().tick = night_tick;
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
TilePosition::new(10, 10, 0),
|
||||
DailyRoutine {
|
||||
entries: vec![RoutineEntry {
|
||||
phase: DayPhase::Evening,
|
||||
location: TilePosition::new(10, 10, 0),
|
||||
activity: "Bar".into(),
|
||||
}],
|
||||
description: "Test".into(),
|
||||
},
|
||||
// Stale activity from Evening, no Night entry
|
||||
ActivityState {
|
||||
activity: "Bar".into(),
|
||||
phase: DayPhase::Evening,
|
||||
started_tick: 1000,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(enter_activity);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<ActivityState>(entity).is_none(),
|
||||
"Stale activity should be cleared when no routine entry for current phase"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_transition_clears_activity_state() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let loc = TilePosition::new(10, 10, 0);
|
||||
let entity = world
|
||||
.spawn((
|
||||
Npc,
|
||||
ActiveSim,
|
||||
loc,
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: loc,
|
||||
activity: "Work".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(20, 20, 0),
|
||||
activity: "Lunch".into(),
|
||||
},
|
||||
],
|
||||
description: "Test".into(),
|
||||
},
|
||||
ActivityState {
|
||||
activity: "Work".into(),
|
||||
phase: DayPhase::Morning,
|
||||
started_tick: 0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// Trigger phase transition to 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);
|
||||
world.flush();
|
||||
|
||||
// ActivityState should be cleared by phase transition
|
||||
assert!(
|
||||
world.get::<ActivityState>(entity).is_none(),
|
||||
"Phase transition should clear ActivityState"
|
||||
);
|
||||
// PathRequest should be set for the new phase location
|
||||
assert!(world.get::<PathRequest>(entity).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::perception::cognitive_delay::CognitiveDelay;
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::contraband::ScanEventBuffer;
|
||||
use crate::simulation::conversation::ConversationEventBuffer;
|
||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
@@ -78,6 +79,7 @@ pub fn compute_observer_snapshot(
|
||||
Option<&CognitiveDelay>,
|
||||
Option<&mut DialogueResponseBuffer>,
|
||||
Option<&mut ScanEventBuffer>,
|
||||
Option<&mut ConversationEventBuffer>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
@@ -105,6 +107,7 @@ pub fn compute_observer_snapshot(
|
||||
cognitive_delay_opt,
|
||||
mut dialogue_response_opt,
|
||||
mut scan_event_buffer_opt,
|
||||
mut conversation_buffer_opt,
|
||||
)) = observer_query.single_mut()
|
||||
else {
|
||||
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
|
||||
@@ -191,6 +194,12 @@ pub fn compute_observer_snapshot(
|
||||
.map(|buf| buf.take())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Drain NPC-to-NPC conversation events (#247, D-078)
|
||||
let (conversation_events, conversation_ended) = conversation_buffer_opt
|
||||
.as_mut()
|
||||
.map(|buf| (buf.take_events(), buf.take_ended()))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Collect sound events audible to the observer (D-038, #124).
|
||||
// Filter by D-018 range: only events the player can hear based on distance.
|
||||
let sound_events = if let Some(ref queue) = sound_queue {
|
||||
@@ -251,6 +260,8 @@ pub fn compute_observer_snapshot(
|
||||
dialogue_response,
|
||||
blocked_entities,
|
||||
scan_events,
|
||||
conversation_events,
|
||||
conversation_ended,
|
||||
sound_events,
|
||||
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,8 @@ use crate::content::line_pool::{
|
||||
};
|
||||
use crate::content::LinePoolIndexResource;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory};
|
||||
use crate::npc::relationships::{TrustEvent, TrustEventQueue};
|
||||
use crate::simulation::monologue::{MonologueBuffer, MonologueState};
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
use crate::simulation::rng::SimRng;
|
||||
@@ -346,6 +348,7 @@ pub fn process_talk_interaction(
|
||||
registry: Res<EntityRegistry>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
|
||||
mut trust_queue: ResMut<TrustEventQueue>,
|
||||
mut player_query: Query<
|
||||
(
|
||||
Entity,
|
||||
@@ -357,7 +360,7 @@ pub fn process_talk_interaction(
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
npc_query: Query<(&DialogueProfile, Option<&CurrentMood>)>,
|
||||
mut npc_query: Query<(&DialogueProfile, Option<&CurrentMood>, Option<&mut InteractionMemory>)>,
|
||||
) {
|
||||
let Some(line_pool) = line_pool else {
|
||||
return;
|
||||
@@ -377,8 +380,8 @@ pub fn process_talk_interaction(
|
||||
|
||||
let target = talk_request.target;
|
||||
|
||||
// Look up NPC dialogue profile and mood
|
||||
let Ok((profile, mood_opt)) = npc_query.get(target) else {
|
||||
// Look up NPC dialogue profile, mood, and interaction history (#325)
|
||||
let Ok((profile, mood_opt, mut interaction_mem_opt)) = npc_query.get_mut(target) else {
|
||||
tracing::debug!(
|
||||
"Talk target {:?} has no DialogueProfile — cannot select dialogue",
|
||||
target
|
||||
@@ -397,7 +400,16 @@ pub fn process_talk_interaction(
|
||||
let access_tiers = available_access_tiers(relationship);
|
||||
|
||||
// Layer 2: Derive active situations from game state
|
||||
let situations = derive_situations(time.day_phase(), relationship);
|
||||
let mut situations = derive_situations(time.day_phase(), relationship);
|
||||
|
||||
// Layer 2 extension: first_meeting / repeated_visit from InteractionMemory (#325, D-028)
|
||||
if let Some(ref mem) = interaction_mem_opt {
|
||||
if mem.is_first_meeting() {
|
||||
situations.push(Situation::FirstMeeting);
|
||||
} else if mem.is_repeated_visit() {
|
||||
situations.push(Situation::RepeatedVisit);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: Trust tier from relationship + confidence (D-075)
|
||||
// Default to Suspects for unknown NPCs — no KG entry means no basis for
|
||||
@@ -498,6 +510,17 @@ pub fn process_talk_interaction(
|
||||
started_tick: time.tick,
|
||||
});
|
||||
|
||||
// Trust progression (#324): successful talk warms the NPC
|
||||
trust_queue.push(TrustEvent::TalkCompleted {
|
||||
npc: target,
|
||||
player: player_entity,
|
||||
});
|
||||
|
||||
// Interaction tracking (#325): record completed talk
|
||||
if let Some(ref mut mem) = interaction_mem_opt {
|
||||
mem.record_talk(time.tick);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Dialogue selected: id={}, speaker={}, location={}, role={}",
|
||||
line.id,
|
||||
@@ -537,8 +560,10 @@ pub fn process_talk_interaction(
|
||||
pub fn process_walk_away(
|
||||
mut commands: Commands,
|
||||
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
|
||||
mut trust_queue: ResMut<TrustEventQueue>,
|
||||
time: Res<SimulationTime>,
|
||||
query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With<PlayerCharacter>>,
|
||||
mut npc_mem_query: Query<Option<&mut InteractionMemory>>,
|
||||
) {
|
||||
let Ok((player_entity, active_dialogue_opt, _walk_away)) = query.single() else {
|
||||
return;
|
||||
@@ -570,6 +595,20 @@ pub fn process_walk_away(
|
||||
},
|
||||
});
|
||||
|
||||
// Trust progression (#324): walk-away reduces NPC trust
|
||||
trust_queue.push(TrustEvent::WalkAway {
|
||||
npc: target,
|
||||
player: player_entity,
|
||||
});
|
||||
|
||||
// Interaction tracking (#325): record notable walk-away event
|
||||
if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) {
|
||||
mem.push_event(InteractionEvent {
|
||||
tick: time.tick,
|
||||
kind: InteractionEventKind::WalkAway,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Walk-away during {:?} dialogue at tick {} (started tick {}): \
|
||||
target {:?} → Tier2 animation + routine deviation",
|
||||
@@ -616,6 +655,7 @@ pub fn process_confrontation_response(
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut rng: ResMut<crate::simulation::rng::SimRng>,
|
||||
mut trust_queue: ResMut<TrustEventQueue>,
|
||||
mut query: Query<
|
||||
(
|
||||
Entity,
|
||||
@@ -626,6 +666,7 @@ pub fn process_confrontation_response(
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
mut npc_mem_query: Query<Option<&mut InteractionMemory>>,
|
||||
) {
|
||||
let Ok((player_entity, confrontation, mut observer_kg, mut monologue_buf, mut monologue_state)) =
|
||||
query.single_mut()
|
||||
@@ -670,10 +711,24 @@ pub fn process_confrontation_response(
|
||||
});
|
||||
monologue_state.last_fired_tick = time.tick;
|
||||
|
||||
// Trust progression (#324): confrontation significantly reduces NPC trust
|
||||
trust_queue.push(TrustEvent::ConfrontationDelivered {
|
||||
npc: target,
|
||||
player: player_entity,
|
||||
});
|
||||
|
||||
// Interaction tracking (#325): record confrontation notable event
|
||||
if let Ok(Some(mut mem)) = npc_mem_query.get_mut(target) {
|
||||
mem.push_event(InteractionEvent {
|
||||
tick: time.tick,
|
||||
kind: InteractionEventKind::Confrontation,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
tick = time.tick,
|
||||
monologue_id = id,
|
||||
"Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike"
|
||||
"Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike + trust penalty"
|
||||
);
|
||||
|
||||
// Clean up marker
|
||||
@@ -1039,6 +1094,7 @@ mod tests {
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
@@ -1708,6 +1764,7 @@ mod tests {
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
@@ -1758,6 +1815,7 @@ mod tests {
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
@@ -1797,6 +1855,7 @@ mod tests {
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
@@ -1836,6 +1895,7 @@ mod tests {
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
|
||||
@@ -5,6 +5,7 @@ use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod contraband;
|
||||
pub mod conversation;
|
||||
pub mod dialogue;
|
||||
pub mod input;
|
||||
pub mod interaction;
|
||||
@@ -48,6 +49,9 @@ impl Plugin for SimulationPlugin {
|
||||
contraband::check_contraband_scan
|
||||
.after(movement::validate_movement)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
conversation::run_npc_conversations
|
||||
.after(movement::validate_movement)
|
||||
.before(sound::collect_sound_events),
|
||||
sound::collect_sound_events
|
||||
.after(movement::validate_movement)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,8 @@
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod constants;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod invariants;
|
||||
pub mod reset;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod rooms;
|
||||
@@ -493,6 +495,48 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
}
|
||||
|
||||
app.insert_resource(snapshots);
|
||||
|
||||
// --- Sprint 14 component fixup ---
|
||||
// Attach MoodState and InteractionMemory to all Npc entities that are
|
||||
// missing them. Gauntlet room builders don't include these yet — this
|
||||
// ensures invariant S14-1/S14-2 pass and the mood/trust systems have
|
||||
// valid component targets.
|
||||
{
|
||||
use crate::npc::interaction::InteractionMemory;
|
||||
use crate::npc::mood::MoodState;
|
||||
use crate::npc::Npc;
|
||||
|
||||
let missing_mood: Vec<bevy_ecs::prelude::Entity> = {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<bevy_ecs::prelude::Entity, (
|
||||
bevy_ecs::prelude::With<Npc>,
|
||||
bevy_ecs::prelude::Without<MoodState>,
|
||||
)>();
|
||||
q.iter(app.world()).collect()
|
||||
};
|
||||
for entity in missing_mood {
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(MoodState::default());
|
||||
}
|
||||
|
||||
let missing_mem: Vec<bevy_ecs::prelude::Entity> = {
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<bevy_ecs::prelude::Entity, (
|
||||
bevy_ecs::prelude::With<Npc>,
|
||||
bevy_ecs::prelude::Without<InteractionMemory>,
|
||||
)>();
|
||||
q.iter(app.world()).collect()
|
||||
};
|
||||
for entity in missing_mem {
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(InteractionMemory::default());
|
||||
}
|
||||
}
|
||||
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
|
||||
@@ -536,6 +580,9 @@ mod tests {
|
||||
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
|
||||
invariants::run_invariants(app.world_mut());
|
||||
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
|
||||
@@ -62,6 +62,8 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
rng_seed: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
rng_seed: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
rng_seed: None,
|
||||
}
|
||||
}
|
||||
@@ -214,6 +216,8 @@ fn generate_msgpack_fixtures() {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
rng_seed: None,
|
||||
};
|
||||
write_fixture(
|
||||
|
||||
@@ -27,6 +27,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
rng_seed: None,
|
||||
}
|
||||
}
|
||||
@@ -259,6 +261,8 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
rng_seed: None,
|
||||
};
|
||||
|
||||
@@ -355,6 +359,8 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
sound_events: vec![],
|
||||
conversation_events: vec![],
|
||||
conversation_ended: vec![],
|
||||
rng_seed: None,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
|
||||
Reference in New Issue
Block a user