feat(simulation): complete interaction and monologue pipelines (#413, #414, #415)

Three fixes to make the gameplay loop functional end-to-end:

- Add Interactable component to NPC spawn so E-prompt detection works
- Build monologue trigger system (enter_location + time_idle) with
  MonologueBuffer/MonologueState components, wire through ObserverSnapshot
  as current_monologue field, decode on client and display via HUD
- Change PlayerAction::Interact from unit to struct variant carrying
  optional target_entity_id and verb fields

Bumps protocol version from 4 to 5. Regenerates MessagePack fixtures.
All 200 tests pass (170 unit + 30 integration).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 19:23:47 +01:00
co-authored by Claude Opus 4.6
parent 8228ef3c1c
commit 3c106aa2d0
27 changed files with 480 additions and 30 deletions
+9
View File
@@ -22,6 +22,9 @@ var player_entity_id: int = 1
# v4 fields (#404/#405)
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
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
@@ -73,6 +76,12 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
nearby_interactions = []
# v5: current_monologue (#414)
if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary:
current_monologue = snapshot.current_monologue
else:
current_monologue = null
# v2: visible_tiles with visibility sectors
# Derives visible_positions when not explicitly provided (real server mode)
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
+10
View File
@@ -309,6 +309,15 @@ func _test_snapshot() -> Dictionary:
],
})
# v5: monologue on first tick (#414)
var monologue: Variant = null
if _test_tick == 1:
monologue = {
"id": "test_enter_001",
"text": "Sova Transit District. Population twelve thousand and change.",
"duration_seconds": 5.0,
}
return {
"tick": _test_tick,
"version": Protocol.PROTOCOL_VERSION,
@@ -324,6 +333,7 @@ func _test_snapshot() -> Dictionary:
"visible_tiles": _test_visible_tiles(),
"visible_positions": _test_visible_positions(),
"nearby_interactions": nearby,
"current_monologue": monologue,
}
# Generate a small test room: 8x6 room with walls, a door, and floor
+12
View File
@@ -22,6 +22,12 @@ func _process(_delta: float) -> void:
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# Show monologue if server sent one this tick (#414)
if GameState.current_monologue != null and monologue_display:
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
GameState.current_monologue = null # Consume — don't re-show next frame
# Track camera to player position every frame (D-015: locked, no panning)
# Camera2D smoothing handles interpolation — we just set the target
camera.global_position = GameState.player_position * Constants.TILE_SIZE
@@ -31,9 +37,15 @@ func _process(_delta: float) -> void:
for input in inputs:
if input.action == InputMapper.Action.INTERACT:
var target_id: int = interaction_prompt.get_interaction_target()
# Always send struct form for Interact (#415) — server expects named fields
if target_id >= 0:
input["action_data"] = {
"target_entity_id": target_id,
"verb": interaction_prompt.get_selected_verb(),
}
else:
input["action_data"] = {
"target_entity_id": null,
"verb": null,
}
SimBridge.send_input(input)
+12 -1
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 4
const PROTOCOL_VERSION: int = 5
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -94,6 +94,16 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
if ni != null:
nearby_interactions.append(ni)
# v5: current_monologue (#414)
var current_monologue: Variant = null
var raw_monologue: Variant = raw.get("current_monologue")
if raw_monologue is Dictionary and raw_monologue.has("text"):
current_monologue = {
"id": str(raw_monologue.get("id", "")),
"text": str(raw_monologue["text"]),
"duration_seconds": float(raw_monologue.get("duration_seconds", 5.0)),
}
return {
"tick": tick,
"entities": entities,
@@ -103,6 +113,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"player_facing": player_facing,
"visible_tiles": visible_tiles,
"nearby_interactions": nearby_interactions,
"current_monologue": current_monologue,
}