refactor(simulation): split the input.rs and dialogue.rs dispatchers (T-1062)
input.rs 2,739 → 858 lines: per-domain action handlers moved to their owning modules (inventory, movement, stance, examine, follow, interaction, save_io, settings, bridge::debug, economy, vision_cone, bookmark, test_world reset + new teleport.rs); input.rs keeps the queue, the thin dispatch table, and pause/cooldown glue. All 9 type_complexity allows dissolved via one PlayerInputQuery alias. dialogue.rs → dialogue/ directory module: selection (631), response (1,473), confrontation (714), mod.rs (226, shared session components + re-exports — public paths preserved). Documented seam deviation: process_walk_away lives with confrontation (D-064/D-063 share the same world-response shape). Mechanical, zero behavior change: determinism + golden_suite byte-identical (independently re-verified); 1,504 lib tests unchanged — 23 input tests moved with their subjects, 53 dialogue tests redistributed, zero deleted. System scheduling registrations untouched (input_plugin.rs 0-line diff). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1838,3 +1838,4 @@ The pre-push hook is the ONLY automatic correctness gate (its own comment, .conf
|
||||
Fix: run tests/run-godot in pre-push when CLIENT_CHANGED > 0 — or record manual-only explicitly and fix team-patterns.md. Related decision: Q-116 (gdlint blocking). Prerequisite worth checking: T-973 (pre-existing bare-run failures, under this initiative) so the new gate starts green.
|
||||
|
||||
Closed: pre-push now runs tests/run-godot (blocking) when CLIENT_CHANGED > 0 — added after T-973 made the suite green and the lead independently re-verified (2536/2536). team-patterns.md gate description updated. ~100s added to client-touching pushes; e2e suites self-skip without the server binary.', NULL, '2026-06-12 14:24:14', '2026-06-12 14:24:14', '2026-06-12 14:24:14', NULL, '89425fbc550ecb2a7fdde019c4f23482', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXR79W28XNN1TB7HKGBP8', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 14:39:13', '2026-06-12 14:39:13', '2026-06-12 14:39:13', NULL, '3e30c4bc9bb82e75990d958bd287e086', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -2093,3 +2093,8 @@ The pre-push hook is the ONLY automatic correctness gate (its own comment, .conf
|
||||
Fix: run tests/run-godot in pre-push when CLIENT_CHANGED > 0 — or record manual-only explicitly and fix team-patterns.md. Related decision: Q-116 (gdlint blocking). Prerequisite worth checking: T-973 (pre-existing bare-run failures, under this initiative) so the new gate starts green.
|
||||
|
||||
Closed: pre-push now runs tests/run-godot (blocking) when CLIENT_CHANGED > 0 — added after T-973 made the suite green and the lead independently re-verified (2536/2536). team-patterns.md gate description updated. ~100s added to client-touching pushes; e2e suites self-skip without the server binary.', 'done', 'high', NULL, 'client', NULL, '2026-06-12 10:40:59', '2026-06-12 14:24:14', NULL, '1764112ab2704e7782054150a9435eb4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPTXR79W28XNN1TB7HKGBP8', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Split the input.rs and dialogue.rs god-module dispatchers', '(description follows in first append)
|
||||
|
||||
Filed 2026-06-12 from the fable-ous.md audit (S-03; citations adversarially verified).
|
||||
simulation/input.rs: 2,739 lines, imports 9 domains (bookmark, bridge::debug, knowledge, settings, economy, interaction, inventory, movement, save_io), 9× type_complexity allows (:485-1120) + 1 too_many_arguments — the dispatch hub for every player verb. simulation/dialogue.rs: 2,785 lines, 3 allows.
|
||||
Extract per-domain input handlers into their owning modules behind a thin dispatch table; split dialogue into talk-selection / confrontation / response-assembly. Mechanical, no behavior change; gate with the existing determinism.rs byte-identical goldens.', 'in_progress', 'medium', NULL, 'server', NULL, '2026-06-12 10:40:59', '2026-06-12 14:39:13', NULL, 'dfb062e89b351f48cb18a684ce5a9531', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::system::SystemParam;
|
||||
|
||||
use crate::bridge::types::{BookmarkCatalog, BookmarkWire, CareerKindWire, SnapshotBuffer};
|
||||
use crate::knowledge::{resolve_culture, CultureResolver};
|
||||
@@ -174,6 +175,102 @@ fn prime_initial_catalog(
|
||||
tracing::debug!("BookmarkPlugin: initial catalog staged for tick-0 snapshot");
|
||||
}
|
||||
|
||||
/// Bundled SystemParam for bookmark-related input handling.
|
||||
///
|
||||
/// Bevy's blanket `IntoSystem` impl covers functions up to 16 parameters.
|
||||
/// Bundling the bookmark params keeps `process_player_input` at exactly 16.
|
||||
#[derive(SystemParam)]
|
||||
pub struct BookmarkInputParams<'w> {
|
||||
pub registry: Option<Res<'w, BookmarkRegistry>>,
|
||||
pub selected: Option<ResMut<'w, SelectedBookmark>>,
|
||||
pub snapshot_buf: Option<ResMut<'w, SnapshotBuffer>>,
|
||||
pub sim_error_buf: Option<ResMut<'w, crate::bridge::types::SimErrorBuffer>>,
|
||||
pub culture: Option<Res<'w, crate::knowledge::CultureResolverResource>>,
|
||||
}
|
||||
|
||||
/// Handle RequestBookmarkCatalog: stage the catalog in `SnapshotBuffer` for
|
||||
/// delivery in the next snapshot.
|
||||
pub fn handle_request_bookmark_catalog(bookmark: &mut BookmarkInputParams) {
|
||||
if let (Some(ref registry), Some(ref mut buf)) =
|
||||
(&bookmark.registry, &mut bookmark.snapshot_buf)
|
||||
{
|
||||
let resolver = bookmark.culture.as_deref().map(|c| &c.0);
|
||||
buf.pending_bookmark_catalog = Some(registry.build_catalog(resolver));
|
||||
tracing::debug!("RequestBookmarkCatalog: catalog staged");
|
||||
} else {
|
||||
tracing::warn!("RequestBookmarkCatalog: BookmarkRegistry or SnapshotBuffer not available");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ConfirmBookmark: validate id + starting location against the registry
|
||||
/// and record the selection. Invalid or duplicate confirms emit a SimError.
|
||||
pub fn handle_confirm_bookmark(
|
||||
bookmark_id: String,
|
||||
starting_location_id: String,
|
||||
registry: &Option<Res<BookmarkRegistry>>,
|
||||
selected_bookmark: &mut Option<ResMut<SelectedBookmark>>,
|
||||
sim_error_buf: &mut Option<ResMut<crate::bridge::types::SimErrorBuffer>>,
|
||||
tick: u64,
|
||||
) {
|
||||
use crate::bridge::types::{SimError, SimErrorKind};
|
||||
|
||||
let Some(ref registry) = registry else {
|
||||
tracing::warn!("ConfirmBookmark: BookmarkRegistry not available");
|
||||
return;
|
||||
};
|
||||
let Some(defn) = registry.get(&bookmark_id) else {
|
||||
let msg = format!("ConfirmBookmark: unknown bookmark_id {:?}", bookmark_id);
|
||||
tracing::warn!("{}", msg);
|
||||
if let Some(ref mut buf) = sim_error_buf {
|
||||
buf.push(SimError {
|
||||
kind: SimErrorKind::ProtocolError,
|
||||
message: msg,
|
||||
tick,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
if !defn.allowed_locations.contains(&starting_location_id) {
|
||||
let msg = format!(
|
||||
"ConfirmBookmark: starting_location_id {:?} not in allowed_locations for {:?}",
|
||||
starting_location_id, bookmark_id
|
||||
);
|
||||
tracing::warn!("{}", msg);
|
||||
if let Some(ref mut buf) = sim_error_buf {
|
||||
buf.push(SimError {
|
||||
kind: SimErrorKind::ProtocolError,
|
||||
message: msg,
|
||||
tick,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(ref mut sel) = selected_bookmark {
|
||||
if sel.bookmark_id.is_some() {
|
||||
let msg = format!(
|
||||
"ConfirmBookmark: bookmark already confirmed ({}), ignoring retry",
|
||||
sel.bookmark_id.as_deref().unwrap_or("?")
|
||||
);
|
||||
tracing::warn!("{}", msg);
|
||||
if let Some(ref mut buf) = sim_error_buf {
|
||||
buf.push(SimError {
|
||||
kind: SimErrorKind::ProtocolError,
|
||||
message: msg,
|
||||
tick,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
sel.bookmark_id = Some(bookmark_id.clone());
|
||||
sel.starting_location_id = Some(starting_location_id.clone());
|
||||
tracing::info!(
|
||||
bookmark_id,
|
||||
starting_location_id,
|
||||
"ConfirmBookmark: selection recorded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register all v0.2 bookmarks.
|
||||
///
|
||||
/// Hard-coded for now (single entry). Promote to TOML loading in Sprint 38+
|
||||
@@ -264,4 +361,160 @@ mod tests {
|
||||
.allowed_locations
|
||||
.contains(&"Unknown System".to_string()));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ConfirmBookmark input-handler tests (moved from input.rs, T-1062)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput, SimErrorBuffer};
|
||||
use crate::simulation::input::{process_player_input, InputQueue};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
fn make_bookmark_world() -> bevy_ecs::world::World {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
world.init_resource::<SelectedBookmark>();
|
||||
world.init_resource::<SimErrorBuffer>();
|
||||
|
||||
let mut registry = BookmarkRegistry::default();
|
||||
registry.insert(BookmarkDefinition {
|
||||
id: BookmarkId("test_bookmark".to_string()),
|
||||
title: "Test Bookmark".into(),
|
||||
subtitle: String::new(),
|
||||
flavor: String::new(),
|
||||
default_location: "Loc A".into(),
|
||||
allowed_locations: vec!["Loc A".into(), "Loc B".into()],
|
||||
career: CareerKind::Tycoon,
|
||||
starting_capital_tractus: 1_000,
|
||||
available: true,
|
||||
});
|
||||
world.insert_resource(registry);
|
||||
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_unknown_id_emits_sim_error() {
|
||||
let mut world = make_bookmark_world();
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "no_such_bookmark".into(),
|
||||
starting_location_id: "Loc A".into(),
|
||||
},
|
||||
});
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert_eq!(
|
||||
errors.len(),
|
||||
1,
|
||||
"expected one SimError for unknown bookmark"
|
||||
);
|
||||
assert_eq!(
|
||||
errors[0].kind,
|
||||
crate::bridge::types::SimErrorKind::ProtocolError
|
||||
);
|
||||
assert!(errors[0].message.contains("unknown bookmark_id"));
|
||||
assert!(
|
||||
world.resource::<SelectedBookmark>().bookmark_id.is_none(),
|
||||
"SelectedBookmark must not be set on error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_invalid_location_emits_sim_error() {
|
||||
let mut world = make_bookmark_world();
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Not A Location".into(),
|
||||
},
|
||||
});
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert_eq!(
|
||||
errors.len(),
|
||||
1,
|
||||
"expected one SimError for invalid location"
|
||||
);
|
||||
assert_eq!(
|
||||
errors[0].kind,
|
||||
crate::bridge::types::SimErrorKind::ProtocolError
|
||||
);
|
||||
assert!(errors[0].message.contains("not in allowed_locations"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_valid_inputs_populate_selected_bookmark() {
|
||||
let mut world = make_bookmark_world();
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Loc B".into(),
|
||||
},
|
||||
});
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"no errors expected for valid ConfirmBookmark"
|
||||
);
|
||||
|
||||
let sel = world.resource::<SelectedBookmark>();
|
||||
assert_eq!(sel.bookmark_id.as_deref(), Some("test_bookmark"));
|
||||
assert_eq!(sel.starting_location_id.as_deref(), Some("Loc B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_bookmark_double_confirm_emits_sim_error() {
|
||||
let mut world = make_bookmark_world();
|
||||
|
||||
// Both confirms at tick=0: processed in order within the same run.
|
||||
// First succeeds; second hits the idempotency guard.
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Loc A".into(),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ConfirmBookmark {
|
||||
bookmark_id: "test_bookmark".into(),
|
||||
starting_location_id: "Loc B".into(),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let errors = world.resource_mut::<SimErrorBuffer>().drain();
|
||||
assert_eq!(errors.len(), 1, "double-confirm must emit ProtocolError");
|
||||
assert_eq!(
|
||||
errors[0].kind,
|
||||
crate::bridge::types::SimErrorKind::ProtocolError
|
||||
);
|
||||
assert!(errors[0].message.contains("already confirmed"));
|
||||
|
||||
// Selection must remain the original, not overwritten
|
||||
let sel = world.resource::<SelectedBookmark>();
|
||||
assert_eq!(sel.starting_location_id.as_deref(), Some("Loc A"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,17 @@ impl DebugCommandBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a debug command from player input.
|
||||
/// `None` means the buffer was never registered (warn and drop).
|
||||
pub fn queue_debug_command(buf: Option<&mut DebugCommandBuffer>, cmd: DebugCommandKind) {
|
||||
match buf {
|
||||
Some(buf) => buf.push(cmd),
|
||||
None => {
|
||||
tracing::warn!("DebugCommand received but DebugCommandBuffer not registered");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -78,6 +78,34 @@ pub fn facing_from_delta(dx: i32, dy: i32) -> FacingDirection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the SetFacing player action: parse the wire direction string and
|
||||
/// set the player's Facing component. Unknown directions warn and no-op.
|
||||
pub fn handle_set_facing(
|
||||
player_query: &mut crate::simulation::input::PlayerInputQuery,
|
||||
commands: &mut Commands,
|
||||
facing: &str,
|
||||
) {
|
||||
let dir = match facing {
|
||||
"North" => Some(FacingDirection::North),
|
||||
"Northeast" => Some(FacingDirection::Northeast),
|
||||
"East" => Some(FacingDirection::East),
|
||||
"Southeast" => Some(FacingDirection::Southeast),
|
||||
"South" => Some(FacingDirection::South),
|
||||
"Southwest" => Some(FacingDirection::Southwest),
|
||||
"West" => Some(FacingDirection::West),
|
||||
"Northwest" => Some(FacingDirection::Northwest),
|
||||
_ => {
|
||||
tracing::warn!("SetFacing: unknown direction {:?}", facing);
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(dir) = dir {
|
||||
if let Ok((entity, _, _, _)) = player_query.single_mut() {
|
||||
commands.entity(entity).insert(Facing(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a visible tile into a vision sector based on facing direction.
|
||||
/// Returns None if the tile falls outside the forward cone.
|
||||
fn classify_tile(
|
||||
|
||||
@@ -120,6 +120,25 @@ pub enum SettingsCommand {
|
||||
Delete { key: String },
|
||||
}
|
||||
|
||||
/// Queue a settings command from player input.
|
||||
/// `None` means the buffer was never registered (warn and drop).
|
||||
pub fn queue_settings_command(buf: Option<&mut SettingsCommandBuffer>, cmd: SettingsCommand) {
|
||||
let label = match cmd {
|
||||
SettingsCommand::Change { .. } => "ChangeSetting",
|
||||
SettingsCommand::RequestAll => "RequestAllSettings",
|
||||
SettingsCommand::Delete { .. } => "DeleteSetting",
|
||||
};
|
||||
match buf {
|
||||
Some(buf) => buf.push(cmd),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"{} received but SettingsCommandBuffer not registered",
|
||||
label
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process settings commands from the IPC buffer.
|
||||
/// Reads SettingsCommandBuffer, writes to SettingsStoreResource,
|
||||
/// and stages a response in SnapshotBuffer.pending_settings_response.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,714 @@
|
||||
//! Confrontation and walk-away — negative conversation-ending world
|
||||
//! responses (D-063 #520, D-064 #427/#519).
|
||||
//!
|
||||
//! Both systems share the same effect shape: target NPC shifts to
|
||||
//! `AnimationTier::Tier2`, a `RoutineDeviation` is recorded, NPC trust drops,
|
||||
//! and the event lands in `InteractionMemory`. Walk-away lives here rather
|
||||
//! than in response-assembly for that reason (T-1062 seam note). Also owns
|
||||
//! the Confront input handler that stages `ConfrontationDelivered`.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::bridge::types::MonologueEvent;
|
||||
use crate::knowledge::types::StableId;
|
||||
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::time::SimulationTime;
|
||||
|
||||
use super::{ActiveDialogue, ConfrontationDelivered, WalkAwayRequest};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System: process_walk_away (D-064)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process walk-away requests during active dialogue (D-064 Phases 2+3).
|
||||
///
|
||||
/// When the player moves (WASD) during an active dialogue, the client sends
|
||||
/// PlayerAction::WalkAway which sets WalkAwayRequest. This system:
|
||||
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047 ambiguous animation)
|
||||
/// 2. NPC routine deviation recorded (storyteller hook)
|
||||
/// 3. Emits IncompleteInteraction knowledge event (recorded in KG)
|
||||
/// 4. Clears ActiveDialogue state
|
||||
/// 5. Removes the WalkAwayRequest marker
|
||||
///
|
||||
/// If no ActiveDialogue is present, removes WalkAwayRequest silently (no-op).
|
||||
///
|
||||
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||
pub fn process_walk_away(
|
||||
mut commands: Commands,
|
||||
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
|
||||
mut trust_queue: ResMut<TrustEventQueue>,
|
||||
mut post_conv_queue: ResMut<crate::simulation::monologue::PostConversationQueue>,
|
||||
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;
|
||||
};
|
||||
|
||||
if let Some(active_dialogue) = active_dialogue_opt {
|
||||
let target = active_dialogue.target;
|
||||
|
||||
// Phase 2, Effect 1: Shift target NPC to Tier 2 animation (D-047)
|
||||
commands
|
||||
.entity(target)
|
||||
.insert(crate::npc::AnimationTier::Tier2);
|
||||
|
||||
// Phase 2, Effect 2: Record routine deviation on target NPC
|
||||
commands
|
||||
.entity(target)
|
||||
.insert(crate::npc::RoutineDeviation {
|
||||
trigger: crate::npc::DeviationTrigger::WalkAway,
|
||||
tick: time.tick,
|
||||
expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS,
|
||||
});
|
||||
|
||||
// Phase 3: Emit IncompleteInteraction knowledge event
|
||||
event_queue.push(crate::knowledge::KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick: time.tick,
|
||||
event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction {
|
||||
target,
|
||||
interaction_type: active_dialogue.interaction_type,
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
// Post-conversation monologue trigger (#119, D-035)
|
||||
post_conv_queue.push(target);
|
||||
|
||||
tracing::debug!(
|
||||
"Walk-away during {:?} dialogue at tick {} (started tick {}): \
|
||||
target {:?} → Tier2 animation + routine deviation",
|
||||
active_dialogue.interaction_type,
|
||||
time.tick,
|
||||
active_dialogue.started_tick,
|
||||
target,
|
||||
);
|
||||
|
||||
commands.entity(player_entity).remove::<ActiveDialogue>();
|
||||
} else {
|
||||
tracing::trace!("WalkAway with no active dialogue — ignored");
|
||||
}
|
||||
|
||||
commands.entity(player_entity).remove::<WalkAwayRequest>();
|
||||
}
|
||||
|
||||
/// Hardcoded confrontation monologue lines (D-063).
|
||||
/// Fired as a monologue spike when the player delivers a confrontation.
|
||||
/// TODO: move to content pools with trigger="confrontation_delivered" (D-028/D-035).
|
||||
const CONFRONTATION_LINES: &[(&str, &str)] = &[
|
||||
(
|
||||
"confront_01",
|
||||
"That changed everything between us. No going back.",
|
||||
),
|
||||
("confront_02", "The look on their face... they know I know."),
|
||||
(
|
||||
"confront_03",
|
||||
"Cards on the table. Let's see what happens next.",
|
||||
),
|
||||
];
|
||||
|
||||
/// Process confrontation world response (#520, D-063).
|
||||
///
|
||||
/// Reads ConfrontationDelivered marker (set by input system), applies three
|
||||
/// server-authoritative effects:
|
||||
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047)
|
||||
/// 2. Observer's KG relationship state decremented (D-033 color fade)
|
||||
/// 3. Monologue spike: immediate monologue line bypassing cooldown
|
||||
///
|
||||
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||
pub fn process_confrontation_response(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut rng: ResMut<crate::simulation::rng::SimRng>,
|
||||
mut trust_queue: ResMut<TrustEventQueue>,
|
||||
mut query: Query<
|
||||
(
|
||||
Entity,
|
||||
&ConfrontationDelivered,
|
||||
&mut KnowledgeGraph,
|
||||
&mut MonologueBuffer,
|
||||
&mut MonologueState,
|
||||
),
|
||||
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()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target = confrontation.target;
|
||||
|
||||
// Effect 1: Shift target NPC to Tier 2 animation (D-047)
|
||||
// + record routine deviation (symmetric with walk-away path)
|
||||
commands.entity(target).insert((
|
||||
crate::npc::AnimationTier::Tier2,
|
||||
crate::npc::RoutineDeviation {
|
||||
trigger: crate::npc::DeviationTrigger::Confrontation,
|
||||
tick: time.tick,
|
||||
expires_at_tick: time.tick + crate::npc::TELL_ESCALATION_DURATION_TICKS,
|
||||
},
|
||||
));
|
||||
|
||||
// Effect 2: Decrement observer's relationship with the target (D-033 color fade)
|
||||
if let Some(target_sid) = registry.to_stable(target) {
|
||||
let old_rel = observer_kg.relationship_with(&target_sid);
|
||||
let new_rel = old_rel.decrement();
|
||||
if new_rel != old_rel {
|
||||
observer_kg.set_relationship(&target_sid, new_rel);
|
||||
tracing::info!(
|
||||
target_id = target_sid.0,
|
||||
?old_rel,
|
||||
?new_rel,
|
||||
"Confrontation: relationship decremented"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Effect 3: Monologue spike — bypass cooldown, fire immediately
|
||||
let idx = rng.rng.random_range(0..CONFRONTATION_LINES.len());
|
||||
let (id, text) = CONFRONTATION_LINES[idx];
|
||||
monologue_buf.set(MonologueEvent {
|
||||
id: id.to_string(),
|
||||
text: text.to_string(),
|
||||
duration_seconds: 5.0,
|
||||
});
|
||||
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 + trust penalty"
|
||||
);
|
||||
|
||||
// Clean up marker
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.remove::<ConfrontationDelivered>();
|
||||
}
|
||||
|
||||
/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063).
|
||||
/// The confrontation response system runs in process_confrontation_response.
|
||||
/// Server-side range check: Confront requires CLOSE_RANGE (same as Talk).
|
||||
pub fn handle_confront(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &crate::simulation::input::PlayerInputQuery,
|
||||
all_positions: &Query<&crate::simulation::movement::TilePosition>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Confront verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Confront: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Server-side range check: reject Confront if target is beyond close range
|
||||
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||
let distance = player_pos
|
||||
.manhattan_distance(target_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
distance,
|
||||
"Confront: target out of range (max {})",
|
||||
crate::simulation::interaction::CLOSE_RANGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(ConfrontationDelivered {
|
||||
target: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
target_id,
|
||||
"Confront: ConfrontationDelivered marker set on player"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bridge::types::RelationshipState;
|
||||
use crate::simulation::dialogue::test_support::setup_dialogue_world;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::rng::SimRng;
|
||||
|
||||
#[test]
|
||||
fn walk_away_during_active_dialogue_emits_event() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 10,
|
||||
},
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
// ActiveDialogue and WalkAwayRequest should be removed
|
||||
assert!(
|
||||
world.get::<ActiveDialogue>(player).is_none(),
|
||||
"ActiveDialogue should be cleared after walk-away"
|
||||
);
|
||||
assert!(
|
||||
world.get::<WalkAwayRequest>(player).is_none(),
|
||||
"WalkAwayRequest should be consumed"
|
||||
);
|
||||
|
||||
// KnowledgeEventQueue should have one IncompleteInteraction event
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
assert_eq!(queue.len(), 1, "should emit exactly one knowledge event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_without_active_dialogue_is_noop() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
// WalkAwayRequest consumed but no event emitted
|
||||
assert!(
|
||||
world.get::<WalkAwayRequest>(player).is_none(),
|
||||
"WalkAwayRequest should be consumed even without dialogue"
|
||||
);
|
||||
|
||||
let queue = world.resource::<KnowledgeEventQueue>();
|
||||
assert!(
|
||||
queue.is_empty(),
|
||||
"no event should be emitted when not in dialogue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_records_in_knowledge_graph() {
|
||||
// Full integration: walk-away → event → KG recording
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.init_resource::<crate::knowledge::ContradictionDetectedQueue>();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Pre-populate player KG with knowledge of the NPC
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
kg,
|
||||
ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 5,
|
||||
},
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Step 1: process_walk_away emits the event
|
||||
let mut schedule1 = bevy_ecs::schedule::Schedule::default();
|
||||
schedule1.add_systems(process_walk_away);
|
||||
schedule1.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
// Step 2: process_knowledge_events applies it to the KG
|
||||
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
|
||||
schedule2.add_systems(crate::knowledge::events::process_knowledge_events);
|
||||
schedule2.run(&mut world);
|
||||
|
||||
// Verify the KG recorded the incomplete interaction
|
||||
let player_kg = world.get::<KnowledgeGraph>(player).unwrap();
|
||||
assert!(
|
||||
player_kg.has_incomplete_interaction(&npc_sid),
|
||||
"KG should record incomplete interaction after walk-away"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Walk-away Phase 2 tests (D-064, #519) ---------------------------------
|
||||
|
||||
#[test]
|
||||
fn walk_away_shifts_npc_to_tier2_animation() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
use crate::npc::AnimationTier;
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 10,
|
||||
},
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let tier = world.get::<AnimationTier>(npc).unwrap();
|
||||
assert_eq!(
|
||||
*tier,
|
||||
AnimationTier::Tier2,
|
||||
"Walk-away should shift NPC to Tier 2 animation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_records_routine_deviation() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
use crate::npc::{DeviationTrigger, RoutineDeviation};
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.resource_mut::<SimulationTime>().tick = 42;
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 10,
|
||||
},
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let deviation = world.get::<RoutineDeviation>(npc).unwrap();
|
||||
assert_eq!(
|
||||
deviation.trigger,
|
||||
DeviationTrigger::WalkAway,
|
||||
"Deviation trigger should be WalkAway"
|
||||
);
|
||||
assert_eq!(
|
||||
deviation.tick, 42,
|
||||
"Deviation should record the walk-away tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_without_dialogue_does_not_affect_npcs() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
use crate::npc::{AnimationTier, RoutineDeviation};
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Player with WalkAwayRequest but NO ActiveDialogue
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnimationTier>(npc).is_none(),
|
||||
"NPC should not get AnimationTier when no dialogue was active"
|
||||
);
|
||||
assert!(
|
||||
world.get::<RoutineDeviation>(npc).is_none(),
|
||||
"NPC should not get RoutineDeviation when no dialogue was active"
|
||||
);
|
||||
}
|
||||
|
||||
// === Confrontation Response Tests (#520, D-063) ===
|
||||
|
||||
#[test]
|
||||
fn confrontation_shifts_npc_to_tier2() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
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)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Known);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let tier = world.get::<crate::npc::AnimationTier>(npc);
|
||||
assert_eq!(
|
||||
tier,
|
||||
Some(&crate::npc::AnimationTier::Tier2),
|
||||
"NPC should shift to Tier2 after confrontation"
|
||||
);
|
||||
|
||||
// RoutineDeviation should be recorded (symmetric with walk-away)
|
||||
let deviation = world.get::<crate::npc::RoutineDeviation>(npc);
|
||||
assert!(
|
||||
deviation.is_some(),
|
||||
"NPC should get RoutineDeviation after confrontation"
|
||||
);
|
||||
assert_eq!(
|
||||
deviation.unwrap().trigger,
|
||||
crate::npc::DeviationTrigger::Confrontation,
|
||||
"Deviation trigger should be Confrontation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confrontation_decrements_relationship() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
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)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Known);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let player_kg = world.get::<KnowledgeGraph>(player).unwrap();
|
||||
assert_eq!(
|
||||
player_kg.relationship_with(&npc_sid),
|
||||
RelationshipState::PersonOfInterest,
|
||||
"Known → PersonOfInterest after confrontation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confrontation_emits_monologue_spike() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
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)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buffer = world.get_mut::<MonologueBuffer>(player).unwrap();
|
||||
let event = buffer.take();
|
||||
assert!(event.is_some(), "Monologue spike should be emitted");
|
||||
assert!(
|
||||
event.unwrap().id.starts_with("confront_"),
|
||||
"Should be a confrontation monologue line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confrontation_clears_marker() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
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)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<ConfrontationDelivered>(player).is_none(),
|
||||
"Marker should be removed after processing"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Dialogue system — D-028 four-layer pipeline (#305), split per T-1062.
|
||||
//!
|
||||
//! Full pipeline: Talk verb → access tier (from KG RelationshipState)
|
||||
//! → situations (from game context) → trust tier (from KG) → topic+mood
|
||||
//! weighted scoring → select line → DialogueResponseBuffer.
|
||||
//!
|
||||
//! Submodules (the dispatcher split, T-1062):
|
||||
//! - [`selection`] — the four-layer filtering/scoring engine + line cooldown
|
||||
//! - [`response`] — Talk/DialogueResponse assembly systems + knowledge grants
|
||||
//! - [`confrontation`] — confrontation + walk-away world responses
|
||||
//!
|
||||
//! This module keeps the shared dialogue components (session markers and
|
||||
//! buffers) and re-exports the public API so existing
|
||||
//! `crate::simulation::dialogue::X` paths keep working.
|
||||
//!
|
||||
//! Integration points:
|
||||
//! - Reads LinePoolIndexResource (server/content/mod.rs)
|
||||
//! - Reads KnowledgeGraph + EntityRegistry for access/trust derivation
|
||||
//! - Reads DialogueProfile on NPCs for pool lookup coordinates
|
||||
//! - Writes DialogueResponseBuffer for snapshot inclusion
|
||||
//! - Uses SimRng for deterministic weighted random selection
|
||||
|
||||
pub mod confrontation;
|
||||
pub mod response;
|
||||
pub mod selection;
|
||||
|
||||
pub use confrontation::{handle_confront, process_confrontation_response, process_walk_away};
|
||||
pub use response::{
|
||||
handle_dialogue_response, handle_talk, process_dialogue_response, process_talk_interaction,
|
||||
};
|
||||
pub use selection::{
|
||||
available_access_tiers, derive_situations, relationship_to_trust, score_line,
|
||||
select_dialogue_line, DialogueCooldownTracker,
|
||||
};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::DialogueResponseEvent;
|
||||
use crate::simulation::line_pool::Mood;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker: player requested Talk interaction with a target NPC this tick.
|
||||
///
|
||||
/// Set by process_player_input when verb == "Talk". Consumed and removed
|
||||
/// by process_talk_interaction each tick.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct TalkRequest {
|
||||
pub target: Entity,
|
||||
}
|
||||
|
||||
/// NPC's dialogue pool coordinates for LinePoolIndex lookup.
|
||||
///
|
||||
/// `location` maps to DialoguePool.location (e.g., "the-terminal").
|
||||
/// `role` maps to DialoguePool.role (e.g., "dock-worker").
|
||||
/// Attached during content spawn; NPCs without this cannot be talked to.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct DialogueProfile {
|
||||
pub location: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
/// NPC's current mood for Layer 4 scoring.
|
||||
///
|
||||
/// Computed from NPC axes (Tolerance, Contentment, recent events).
|
||||
/// v0.1: set during spawn or defaults to Comfortable.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct CurrentMood(pub Mood);
|
||||
|
||||
impl Default for CurrentMood {
|
||||
fn default() -> Self {
|
||||
Self(Mood::Content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks an active dialogue session between the player and an NPC.
|
||||
///
|
||||
/// Set by `process_talk_interaction` when a dialogue line is selected.
|
||||
/// Cleared by `process_walk_away` (walk-away, D-064) or when dialogue
|
||||
/// ends naturally (future: multi-line exchanges).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ActiveDialogue {
|
||||
pub target: Entity,
|
||||
pub interaction_type: crate::knowledge::events::InteractionType,
|
||||
pub started_tick: u64,
|
||||
}
|
||||
|
||||
/// Marker: player submitted a dialogue response this tick (#539).
|
||||
///
|
||||
/// Set by process_player_input when PlayerAction::DialogueResponse is received.
|
||||
/// Consumed and removed by process_dialogue_response each tick.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct DialogueResponseRequest {
|
||||
pub target: Entity,
|
||||
pub response_id: String,
|
||||
}
|
||||
|
||||
/// Marker: player walked away during active dialogue this tick (D-064).
|
||||
///
|
||||
/// Set by process_player_input when PlayerAction::WalkAway is received.
|
||||
/// Consumed by process_walk_away each tick.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct WalkAwayRequest;
|
||||
|
||||
/// Marker: player delivered a confrontation this tick (#520, D-063).
|
||||
///
|
||||
/// Set by process_player_input when Interact{verb: "Confront"} is received.
|
||||
/// Consumed by process_confrontation_response each tick. Triggers:
|
||||
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047)
|
||||
/// 2. Observer KG relationship state decremented (D-033 color fade)
|
||||
/// 3. Monologue spike event emitted
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ConfrontationDelivered {
|
||||
pub target: Entity,
|
||||
}
|
||||
|
||||
/// Buffer holding the dialogue response for snapshot inclusion.
|
||||
///
|
||||
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct DialogueResponseBuffer {
|
||||
pub(crate) response: Option<DialogueResponseEvent>,
|
||||
}
|
||||
|
||||
impl DialogueResponseBuffer {
|
||||
/// Drain and return the dialogue response, leaving the buffer empty.
|
||||
pub fn take(&mut self) -> Option<DialogueResponseEvent> {
|
||||
self.response.take()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared world/pool builders for the dialogue submodule tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::knowledge::content_registry::ContentEntityRegistry;
|
||||
use crate::knowledge::registry::EntityRegistry;
|
||||
use crate::npc::relationships::TrustEventQueue;
|
||||
use crate::simulation::line_pool::Mood;
|
||||
use crate::simulation::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Situation, Topic,
|
||||
TrustTier,
|
||||
};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
pub(crate) fn setup_dialogue_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<ContentEntityRegistry>();
|
||||
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
||||
world.init_resource::<TrustEventQueue>();
|
||||
world.init_resource::<crate::simulation::monologue::PostConversationQueue>();
|
||||
world
|
||||
}
|
||||
|
||||
pub(crate) fn build_test_line_pool() -> LinePoolIndex {
|
||||
let mut index = LinePoolIndex::default();
|
||||
let lines = vec![
|
||||
IndexedDialogueLine {
|
||||
id: "test_d_001".to_string(),
|
||||
text: "Welcome to the terminal.".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
access: vec![AccessTier::Public],
|
||||
trust: TrustTier::Surface,
|
||||
situation: vec![Situation::Routine, Situation::Social],
|
||||
topic: vec![],
|
||||
mood: vec![],
|
||||
tags: vec![],
|
||||
knowledge_grant: None,
|
||||
},
|
||||
IndexedDialogueLine {
|
||||
id: "test_d_002".to_string(),
|
||||
text: "I've seen some strange cargo lately.".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
access: vec![AccessTier::Peer],
|
||||
trust: TrustTier::Surface,
|
||||
situation: vec![Situation::Routine, Situation::Investigation],
|
||||
topic: vec![Topic::Cargo],
|
||||
mood: vec![Mood::Suspicious],
|
||||
tags: vec![],
|
||||
knowledge_grant: None,
|
||||
},
|
||||
IndexedDialogueLine {
|
||||
id: "test_d_003".to_string(),
|
||||
text: "The night shifts have been quiet.".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
access: vec![AccessTier::Public],
|
||||
trust: TrustTier::Surface,
|
||||
situation: vec![Situation::NightShift],
|
||||
topic: vec![Topic::Routine],
|
||||
mood: vec![Mood::Content],
|
||||
tags: vec![],
|
||||
knowledge_grant: None,
|
||||
},
|
||||
IndexedDialogueLine {
|
||||
id: "test_d_004".to_string(),
|
||||
text: "There's something I need to tell you about the manifests.".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
access: vec![AccessTier::Insider],
|
||||
trust: TrustTier::Real,
|
||||
situation: vec![Situation::Investigation],
|
||||
topic: vec![Topic::Cargo, Topic::Investigation],
|
||||
mood: vec![Mood::Suspicious],
|
||||
tags: vec![],
|
||||
knowledge_grant: None,
|
||||
},
|
||||
];
|
||||
|
||||
let pool = IndexedDialoguePool {
|
||||
location: "the-terminal".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
lines,
|
||||
};
|
||||
index.dialogue.insert(
|
||||
("the-terminal".to_string(), "dock-worker".to_string()),
|
||||
pool,
|
||||
);
|
||||
index
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,631 @@
|
||||
//! Talk-selection — the D-028 four-layer dialogue filtering engine (#305).
|
||||
//!
|
||||
//! Layers 1-3 (access, situation, trust) are delegated to
|
||||
//! `LinePoolIndex::query_dialogue`. Layer 4 (topic + mood weighted selection)
|
||||
//! is implemented here, together with the per-line cooldown tracker (#338)
|
||||
//! and the shared `run_dialogue_pipeline` entry point used by the
|
||||
//! response-assembly systems.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::bridge::types::RelationshipState;
|
||||
use crate::npc::interaction::InteractionMemory;
|
||||
use crate::simulation::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
|
||||
};
|
||||
|
||||
/// Cooldown ticks before the same dialogue line can be selected again.
|
||||
/// At 10 ticks/game-minute, 600 ticks = 1 game-hour.
|
||||
pub(crate) const LINE_COOLDOWN_TICKS: u64 = 600;
|
||||
|
||||
/// Per-player cooldown tracker for dialogue line variety (#338).
|
||||
///
|
||||
/// Prevents the same line from being selected within LINE_COOLDOWN_TICKS.
|
||||
/// Entries older than the cooldown window are pruned each query.
|
||||
///
|
||||
/// Design note: this is player-global, not per-NPC. Line IDs are NPC-scoped
|
||||
/// per D-035 (`{template}_{d|m|e}_{###}`), so cross-NPC collisions don't occur
|
||||
/// in practice. If a future sprint introduces shared line IDs across roles,
|
||||
/// the key should become `(StableId, line_id)` instead.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct DialogueCooldownTracker {
|
||||
used: std::collections::BTreeMap<String, u64>, // line_id → tick_used (D-041)
|
||||
}
|
||||
|
||||
impl DialogueCooldownTracker {
|
||||
/// Record that a line was used at the given tick.
|
||||
pub fn record(&mut self, line_id: &str, tick: u64) {
|
||||
self.used.insert(line_id.to_string(), tick);
|
||||
}
|
||||
|
||||
/// Check if a line is on cooldown at the given tick.
|
||||
pub fn is_on_cooldown(&self, line_id: &str, tick: u64) -> bool {
|
||||
self.used
|
||||
.get(line_id)
|
||||
.is_some_and(|used_tick| tick.saturating_sub(*used_tick) < LINE_COOLDOWN_TICKS)
|
||||
}
|
||||
|
||||
/// Prune entries older than the cooldown window.
|
||||
pub fn prune(&mut self, tick: u64) {
|
||||
self.used
|
||||
.retain(|_, used_tick| tick.saturating_sub(*used_tick) < LINE_COOLDOWN_TICKS);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapping functions (D-028 Layer 1 + Layer 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Map RelationshipState to the set of AccessTiers the player can access.
|
||||
///
|
||||
/// Per sprint briefing:
|
||||
/// - Unknown → Public only
|
||||
/// - Known → Public + Peer
|
||||
/// - Friendly → Public + Peer + Insider
|
||||
/// - PersonOfInterest → Public + Peer + Authority (detective investigation context)
|
||||
/// - Hostile → Hostile only
|
||||
pub fn available_access_tiers(relationship: RelationshipState) -> Vec<AccessTier> {
|
||||
match relationship {
|
||||
RelationshipState::Unknown => vec![AccessTier::Public],
|
||||
RelationshipState::Known => vec![AccessTier::Public, AccessTier::Peer],
|
||||
RelationshipState::Friendly => {
|
||||
vec![AccessTier::Public, AccessTier::Peer, AccessTier::Insider]
|
||||
}
|
||||
RelationshipState::PersonOfInterest => {
|
||||
vec![AccessTier::Public, AccessTier::Peer, AccessTier::Authority]
|
||||
}
|
||||
RelationshipState::Hostile => vec![AccessTier::Hostile],
|
||||
}
|
||||
}
|
||||
|
||||
/// Map RelationshipState + KnowledgeConfidence to the player's effective TrustTier.
|
||||
///
|
||||
/// D-075 layered gate: trust requires BOTH relationship depth AND knowledge depth.
|
||||
/// - Secret: Friendly + KnowsDetails+ (deep rapport + actionable knowledge)
|
||||
/// - Real: (Friendly or Known) + KnowsOf+ (rapport + substantive knowledge)
|
||||
/// - Surface: everything else (baseline, always available)
|
||||
///
|
||||
/// Map relationship + knowledge confidence to trust tier (D-075).
|
||||
///
|
||||
/// Trust tier gates which dialogue lines are available. The layered gate
|
||||
/// requires BOTH sufficient relationship AND sufficient KG confidence:
|
||||
/// Surface: any relationship, any confidence (baseline)
|
||||
/// Real: (Friendly|Known) + KnowsOf+ (rapport + substantive knowledge)
|
||||
/// Secret: Friendly + KnowsDetails+ (deep rapport + actionable knowledge)
|
||||
///
|
||||
/// KnowledgeConfidence ordering is load-bearing here — the >= comparison
|
||||
/// relies on the derive(PartialOrd) order: Suspects < KnowsOf < KnowsDetails < Direct.
|
||||
///
|
||||
/// Unknown NPCs (no KG entry) default to Suspects, yielding Surface tier.
|
||||
/// This is correct: you can't have deep dialogue with someone you know nothing about.
|
||||
pub fn relationship_to_trust(
|
||||
relationship: RelationshipState,
|
||||
confidence: crate::knowledge::types::KnowledgeConfidence,
|
||||
) -> TrustTier {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
|
||||
match relationship {
|
||||
RelationshipState::Friendly if confidence >= KnowledgeConfidence::KnowsDetails => {
|
||||
TrustTier::Secret
|
||||
}
|
||||
RelationshipState::Friendly | RelationshipState::Known
|
||||
if confidence >= KnowledgeConfidence::KnowsOf =>
|
||||
{
|
||||
TrustTier::Real
|
||||
}
|
||||
_ => TrustTier::Surface,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Situation derivation (D-028 Layer 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Derive active Situation tags from game state.
|
||||
///
|
||||
/// Maps DayPhase + relationship context to 1-3 active situations.
|
||||
/// Not hardcoded per sprint briefing — uses a mapping table.
|
||||
pub fn derive_situations(
|
||||
day_phase: crate::simulation::time::DayPhase,
|
||||
relationship: RelationshipState,
|
||||
) -> Vec<Situation> {
|
||||
use crate::simulation::time::DayPhase;
|
||||
|
||||
let mut situations = vec![Situation::Routine]; // Always active baseline
|
||||
|
||||
// Day phase → situation mapping
|
||||
match day_phase {
|
||||
DayPhase::Morning => situations.push(Situation::ShiftStart),
|
||||
DayPhase::Afternoon => situations.push(Situation::Social),
|
||||
DayPhase::Evening => {
|
||||
situations.push(Situation::BarEvening);
|
||||
situations.push(Situation::Social);
|
||||
}
|
||||
DayPhase::Night => situations.push(Situation::NightShift),
|
||||
}
|
||||
|
||||
// Relationship context
|
||||
if relationship == RelationshipState::PersonOfInterest {
|
||||
situations.push(Situation::Investigation);
|
||||
}
|
||||
|
||||
situations
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 4: Topic + Mood weighted selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Score a dialogue line by topic and mood match.
|
||||
///
|
||||
/// Scoring:
|
||||
/// - Base score: 1 (topic/mood-neutral lines always eligible)
|
||||
/// - Mood match: +3 if NPC's CurrentMood is in line.mood
|
||||
/// - Topic match: +2 per matching topic
|
||||
///
|
||||
/// Returns 0 only for lines on cooldown (caller handles).
|
||||
pub fn score_line(
|
||||
line: &IndexedDialogueLine,
|
||||
npc_mood: Option<Mood>,
|
||||
active_topics: &[Topic],
|
||||
) -> u32 {
|
||||
let mut score: u32 = 1; // Base score — no line is excluded by Layer 4
|
||||
|
||||
// Mood match
|
||||
if let Some(mood) = npc_mood {
|
||||
if line.mood.contains(&mood) {
|
||||
score += 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Topic match
|
||||
for topic in active_topics {
|
||||
if line.topic.contains(topic) {
|
||||
score += 2;
|
||||
}
|
||||
}
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
/// Select a dialogue line from Layer 1-3 filtered candidates using Layer 4 scoring.
|
||||
///
|
||||
/// Performs weighted random selection: lines with higher topic/mood match scores
|
||||
/// are more likely to be chosen. Lines on cooldown are excluded.
|
||||
///
|
||||
/// Returns None if no eligible lines remain after cooldown filtering.
|
||||
pub fn select_dialogue_line<'a>(
|
||||
candidates: &[&'a IndexedDialogueLine],
|
||||
npc_mood: Option<Mood>,
|
||||
active_topics: &[Topic],
|
||||
cooldown: &DialogueCooldownTracker,
|
||||
tick: u64,
|
||||
rng: &mut impl Rng,
|
||||
) -> Option<&'a IndexedDialogueLine> {
|
||||
// Score and filter by cooldown
|
||||
let scored: Vec<(&IndexedDialogueLine, u32)> = candidates
|
||||
.iter()
|
||||
.filter(|line| !cooldown.is_on_cooldown(&line.id, tick))
|
||||
.map(|line| (*line, score_line(line, npc_mood, active_topics)))
|
||||
.collect();
|
||||
|
||||
if scored.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Weighted random selection — score_line always returns >= 1 (base score),
|
||||
// so total_weight > 0 is guaranteed when scored is non-empty.
|
||||
let total_weight: u32 = scored.iter().map(|(_, s)| s).sum();
|
||||
|
||||
let mut roll = rng.random_range(0..total_weight);
|
||||
for (line, weight) in &scored {
|
||||
if roll < *weight {
|
||||
return Some(line);
|
||||
}
|
||||
roll -= weight;
|
||||
}
|
||||
|
||||
unreachable!("weighted selection with total_weight > 0 must select a line")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared pipeline: Layers 1-4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the full D-028 four-layer dialogue pipeline and return a selected line.
|
||||
///
|
||||
/// Shared by `process_talk_interaction` and `process_dialogue_response` to
|
||||
/// avoid duplicating the L1-L4 query + scoring logic. Callers handle the
|
||||
/// result differently (initial Talk sets ActiveDialogue; follow-up may clear it).
|
||||
pub(crate) fn run_dialogue_pipeline<'a>(
|
||||
line_pool: &'a crate::simulation::line_pool::LinePoolIndex,
|
||||
location: &str,
|
||||
role: &str,
|
||||
relationship: RelationshipState,
|
||||
confidence: crate::knowledge::types::KnowledgeConfidence,
|
||||
day_phase: crate::simulation::time::DayPhase,
|
||||
interaction_mem: Option<&InteractionMemory>,
|
||||
npc_mood: Option<Mood>,
|
||||
cooldown: &DialogueCooldownTracker,
|
||||
tick: u64,
|
||||
rng: &mut impl Rng,
|
||||
) -> Option<&'a IndexedDialogueLine> {
|
||||
// Layer 1: Access tiers from relationship
|
||||
let access_tiers = available_access_tiers(relationship);
|
||||
|
||||
// Layer 2: Derive active situations from game state
|
||||
let mut situations = derive_situations(day_phase, relationship);
|
||||
|
||||
// Layer 2 extension: first_meeting / repeated_visit from InteractionMemory (#325, D-028)
|
||||
if let Some(mem) = interaction_mem {
|
||||
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)
|
||||
let trust = relationship_to_trust(relationship, confidence);
|
||||
|
||||
// Query Layers 1-3: collect candidates across all available access tiers
|
||||
let mut candidates: Vec<&IndexedDialogueLine> = Vec::new();
|
||||
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
|
||||
|
||||
for access in &access_tiers {
|
||||
let results = line_pool.query_dialogue(location, role, *access, &situations, trust);
|
||||
for line in results {
|
||||
if seen_ids.insert(&line.id) {
|
||||
candidates.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Layer 4: Topic + mood weighted selection
|
||||
let active_topics: Vec<Topic> = Vec::new(); // v0.1: no topic context yet
|
||||
|
||||
select_dialogue_line(&candidates, npc_mood, &active_topics, cooldown, tick, rng)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::SeedableRng;
|
||||
|
||||
// -- Mapping tests -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn access_tiers_unknown_gets_public() {
|
||||
let tiers = available_access_tiers(RelationshipState::Unknown);
|
||||
assert_eq!(tiers, vec![AccessTier::Public]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_tiers_known_gets_public_and_peer() {
|
||||
let tiers = available_access_tiers(RelationshipState::Known);
|
||||
assert!(tiers.contains(&AccessTier::Public));
|
||||
assert!(tiers.contains(&AccessTier::Peer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_tiers_friendly_includes_insider() {
|
||||
let tiers = available_access_tiers(RelationshipState::Friendly);
|
||||
assert!(tiers.contains(&AccessTier::Insider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_tiers_poi_includes_authority() {
|
||||
let tiers = available_access_tiers(RelationshipState::PersonOfInterest);
|
||||
assert!(tiers.contains(&AccessTier::Authority));
|
||||
assert!(tiers.contains(&AccessTier::Peer));
|
||||
assert!(!tiers.contains(&AccessTier::Insider));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_tiers_hostile_only_hostile() {
|
||||
let tiers = available_access_tiers(RelationshipState::Hostile);
|
||||
assert_eq!(tiers, vec![AccessTier::Hostile]);
|
||||
}
|
||||
|
||||
// -- Trust tier tests (D-075: layered confidence gate) --------------------
|
||||
|
||||
#[test]
|
||||
fn trust_friendly_knows_details_is_secret() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(
|
||||
RelationshipState::Friendly,
|
||||
KnowledgeConfidence::KnowsDetails
|
||||
),
|
||||
TrustTier::Secret
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_friendly_direct_is_secret() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Direct),
|
||||
TrustTier::Secret
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_friendly_knows_of_is_real() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::KnowsOf),
|
||||
TrustTier::Real
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_friendly_suspects_is_surface() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Suspects),
|
||||
TrustTier::Surface
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_known_knows_of_is_real() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::KnowsOf),
|
||||
TrustTier::Real
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_known_suspects_is_surface() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::Suspects),
|
||||
TrustTier::Surface
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_unknown_is_always_surface() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Unknown, KnowledgeConfidence::Direct),
|
||||
TrustTier::Surface
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_poi_is_always_surface() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
// PersonOfInterest uses Authority access, not trust depth
|
||||
assert_eq!(
|
||||
relationship_to_trust(
|
||||
RelationshipState::PersonOfInterest,
|
||||
KnowledgeConfidence::KnowsDetails
|
||||
),
|
||||
TrustTier::Surface
|
||||
);
|
||||
}
|
||||
|
||||
// -- Situation derivation tests ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn situations_always_include_routine() {
|
||||
use crate::simulation::time::DayPhase;
|
||||
for phase in [
|
||||
DayPhase::Morning,
|
||||
DayPhase::Afternoon,
|
||||
DayPhase::Evening,
|
||||
DayPhase::Night,
|
||||
] {
|
||||
let sits = derive_situations(phase, RelationshipState::Unknown);
|
||||
assert!(
|
||||
sits.contains(&Situation::Routine),
|
||||
"Routine must always be present for {:?}",
|
||||
phase
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn situations_morning_includes_shift_start() {
|
||||
use crate::simulation::time::DayPhase;
|
||||
let sits = derive_situations(DayPhase::Morning, RelationshipState::Unknown);
|
||||
assert!(sits.contains(&Situation::ShiftStart));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn situations_evening_includes_bar_evening() {
|
||||
use crate::simulation::time::DayPhase;
|
||||
let sits = derive_situations(DayPhase::Evening, RelationshipState::Unknown);
|
||||
assert!(sits.contains(&Situation::BarEvening));
|
||||
assert!(sits.contains(&Situation::Social));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn situations_poi_adds_investigation() {
|
||||
use crate::simulation::time::DayPhase;
|
||||
let sits = derive_situations(DayPhase::Morning, RelationshipState::PersonOfInterest);
|
||||
assert!(sits.contains(&Situation::Investigation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn situations_non_poi_no_investigation() {
|
||||
use crate::simulation::time::DayPhase;
|
||||
let sits = derive_situations(DayPhase::Morning, RelationshipState::Known);
|
||||
assert!(!sits.contains(&Situation::Investigation));
|
||||
}
|
||||
|
||||
// -- Layer 4 scoring tests -----------------------------------------------
|
||||
|
||||
fn make_line(id: &str, topics: &[Topic], moods: &[Mood]) -> IndexedDialogueLine {
|
||||
IndexedDialogueLine {
|
||||
id: id.to_string(),
|
||||
text: format!("Text for {}", id),
|
||||
role: "worker".to_string(),
|
||||
access: vec![AccessTier::Public],
|
||||
trust: TrustTier::Surface,
|
||||
situation: vec![Situation::Routine],
|
||||
topic: topics.to_vec(),
|
||||
mood: moods.to_vec(),
|
||||
tags: vec![],
|
||||
knowledge_grant: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_base_is_one_for_neutral_line() {
|
||||
let line = make_line("neutral", &[], &[]);
|
||||
assert_eq!(score_line(&line, None, &[]), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_mood_match_adds_three() {
|
||||
let line = make_line("moody", &[], &[Mood::Anxious]);
|
||||
assert_eq!(score_line(&line, Some(Mood::Anxious), &[]), 4); // 1 base + 3 mood
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_mood_mismatch_stays_base() {
|
||||
let line = make_line("moody", &[], &[Mood::Anxious]);
|
||||
assert_eq!(score_line(&line, Some(Mood::Warm), &[]), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_topic_match_adds_two_each() {
|
||||
let line = make_line("topical", &[Topic::Cargo, Topic::Danger], &[]);
|
||||
assert_eq!(score_line(&line, None, &[Topic::Cargo]), 3); // 1 + 2
|
||||
assert_eq!(score_line(&line, None, &[Topic::Cargo, Topic::Danger]), 5); // 1 + 2 + 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_combined_mood_and_topic() {
|
||||
let line = make_line("both", &[Topic::Cargo], &[Mood::Suspicious]);
|
||||
assert_eq!(
|
||||
score_line(&line, Some(Mood::Suspicious), &[Topic::Cargo]),
|
||||
6 // 1 + 3 + 2
|
||||
);
|
||||
}
|
||||
|
||||
// -- Cooldown tracker tests ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cooldown_tracks_used_lines() {
|
||||
let mut tracker = DialogueCooldownTracker::default();
|
||||
tracker.record("line_001", 100);
|
||||
assert!(tracker.is_on_cooldown("line_001", 100));
|
||||
assert!(tracker.is_on_cooldown("line_001", 100 + LINE_COOLDOWN_TICKS - 1));
|
||||
assert!(!tracker.is_on_cooldown("line_001", 100 + LINE_COOLDOWN_TICKS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_different_line_not_affected() {
|
||||
let mut tracker = DialogueCooldownTracker::default();
|
||||
tracker.record("line_001", 100);
|
||||
assert!(!tracker.is_on_cooldown("line_002", 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_prune_removes_old_entries() {
|
||||
let mut tracker = DialogueCooldownTracker::default();
|
||||
tracker.record("old", 0);
|
||||
tracker.record("recent", LINE_COOLDOWN_TICKS);
|
||||
tracker.prune(LINE_COOLDOWN_TICKS);
|
||||
assert_eq!(tracker.used.len(), 1);
|
||||
assert!(tracker.used.contains_key("recent"));
|
||||
}
|
||||
|
||||
// -- Selection tests -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn select_returns_none_when_empty() {
|
||||
let candidates: Vec<&IndexedDialogueLine> = vec![];
|
||||
let cooldown = DialogueCooldownTracker::default();
|
||||
let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42);
|
||||
|
||||
let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_returns_none_when_all_on_cooldown() {
|
||||
let line = make_line("only", &[], &[]);
|
||||
let candidates = vec![&line];
|
||||
let mut cooldown = DialogueCooldownTracker::default();
|
||||
cooldown.record("only", 0);
|
||||
let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42);
|
||||
|
||||
let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_picks_from_candidates() {
|
||||
let line_a = make_line("a", &[], &[]);
|
||||
let line_b = make_line("b", &[], &[]);
|
||||
let candidates = vec![&line_a, &line_b];
|
||||
let cooldown = DialogueCooldownTracker::default();
|
||||
let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(42);
|
||||
|
||||
let result = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng);
|
||||
assert!(result.is_some());
|
||||
let id = &result.unwrap().id;
|
||||
assert!(id == "a" || id == "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_deterministic_with_same_seed() {
|
||||
let line_a = make_line("a", &[], &[]);
|
||||
let line_b = make_line("b", &[Topic::Cargo], &[]);
|
||||
let line_c = make_line("c", &[], &[Mood::Anxious]);
|
||||
let candidates = vec![&line_a, &line_b, &line_c];
|
||||
let cooldown = DialogueCooldownTracker::default();
|
||||
|
||||
let mut rng1 = rand_chacha::ChaCha20Rng::seed_from_u64(42);
|
||||
let mut rng2 = rand_chacha::ChaCha20Rng::seed_from_u64(42);
|
||||
|
||||
let r1 = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng1);
|
||||
let r2 = select_dialogue_line(&candidates, None, &[], &cooldown, 0, &mut rng2);
|
||||
assert_eq!(r1.unwrap().id, r2.unwrap().id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_favors_higher_scored_lines() {
|
||||
// Line with matching mood gets +3, so should be selected more often
|
||||
let neutral = make_line("neutral", &[], &[]);
|
||||
let matched = make_line("matched", &[], &[Mood::Anxious]);
|
||||
let candidates = vec![&neutral, &matched];
|
||||
let cooldown = DialogueCooldownTracker::default();
|
||||
|
||||
let mut match_count = 0;
|
||||
for seed in 0..100 {
|
||||
let mut rng = rand_chacha::ChaCha20Rng::seed_from_u64(seed);
|
||||
if let Some(line) = select_dialogue_line(
|
||||
&candidates,
|
||||
Some(Mood::Anxious),
|
||||
&[],
|
||||
&cooldown,
|
||||
0,
|
||||
&mut rng,
|
||||
) {
|
||||
if line.id == "matched" {
|
||||
match_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// matched has score 4, neutral has score 1, so ~80% should be matched
|
||||
assert!(
|
||||
match_count > 60,
|
||||
"matched line should be selected most of the time, got {}/100",
|
||||
match_count
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -285,6 +285,20 @@ pub struct EconQueryBuffer {
|
||||
pub pending: Option<String>,
|
||||
}
|
||||
|
||||
/// Queue an `EconStateQuery` from player input.
|
||||
/// `None` means the economy is not loaded (debug-log and drop — the query
|
||||
/// buffer is only registered when the economy plugin is active).
|
||||
pub fn queue_econ_state_query(buf: Option<&mut EconQueryBuffer>, system_id: String) {
|
||||
match buf {
|
||||
Some(buf) => buf.pending = Some(system_id),
|
||||
None => {
|
||||
tracing::debug!(
|
||||
"EconStateQuery received but EconQueryBuffer not registered — economy not loaded"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System: serve a pending `EconStateQuery` by building an `EconomySnapshot`
|
||||
/// and storing it in `SnapshotBuffer.pending_economy_response`.
|
||||
///
|
||||
|
||||
@@ -285,6 +285,54 @@ pub fn process_examine_interaction(
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
}
|
||||
|
||||
/// Handle Examine verb: insert ExamineRequest marker on the player entity (#242).
|
||||
/// Examine is available at close range (same as Talk). Range check here matches
|
||||
/// the server-side guard in process_examine_interaction.
|
||||
pub fn handle_examine(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &crate::simulation::input::PlayerInputQuery,
|
||||
all_positions: &Query<&TilePosition>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Examine verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = crate::knowledge::StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Examine: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Server-side range check: reject Examine if target is beyond close range
|
||||
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||
let distance = player_pos
|
||||
.manhattan_distance(target_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
if distance > CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
distance,
|
||||
"Examine: target out of range (max {})",
|
||||
CLOSE_RANGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands.entity(player_entity).insert(ExamineRequest {
|
||||
target: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(target_id, "Examine: ExamineRequest marker set on player");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -239,6 +239,58 @@ pub fn update_follow_state(
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Follow verb: designate an NPC as follow target (#241).
|
||||
/// Sets FollowTarget on the player entity. Replaces any existing follow target.
|
||||
/// Server-side range check: Follow requires CLOSE_RANGE (same as Talk).
|
||||
pub fn handle_follow(
|
||||
commands: &mut Commands,
|
||||
registry: &crate::knowledge::EntityRegistry,
|
||||
player_query: &crate::simulation::input::PlayerInputQuery,
|
||||
all_positions: &Query<&TilePosition>,
|
||||
target_entity_id: Option<u64>,
|
||||
current_tick: u64,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Follow verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = crate::knowledge::StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Follow: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Server-side range check: reject Follow if target is beyond close range
|
||||
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||
let distance = player_pos
|
||||
.manhattan_distance(target_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
distance,
|
||||
"Follow: target out of range (max {})",
|
||||
crate::simulation::interaction::CLOSE_RANGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands.entity(player_entity).insert(FollowTarget {
|
||||
target: target_entity,
|
||||
started_tick: current_tick,
|
||||
proximity_ticks: 0,
|
||||
los_lost_ticks: 0,
|
||||
});
|
||||
|
||||
tracing::debug!(target_id, "Follow: FollowTarget set on player");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+69
-1950
File diff suppressed because it is too large
Load Diff
@@ -481,6 +481,96 @@ pub fn process_terminal_interaction(
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle door Open/Close: insert `DoorInteractRequest` on the player entity (#246).
|
||||
///
|
||||
/// The actual walkability toggle is done by `process_door_interaction` which
|
||||
/// reads the request and modifies `WalkabilityMap`. The split keeps system
|
||||
/// ordering explicit and avoids mutable resource conflicts in one system.
|
||||
pub fn handle_door_interact(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &crate::simulation::input::PlayerInputQuery,
|
||||
door_states: &Query<&DoorState>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Door verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Door interact: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Verify target has DoorState before inserting request
|
||||
if door_states.get(target_entity).is_err() {
|
||||
tracing::warn!(target_id, "Door verb on entity without DoorState — ignored");
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok((player_entity, _, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
commands.entity(player_entity).insert(DoorInteractRequest {
|
||||
door_entity: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
target_id,
|
||||
"Door interact: DoorInteractRequest inserted on player"
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle Terminal Use: insert `TerminalInteractRequest` on the player entity (#246).
|
||||
pub fn handle_terminal_interact(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &crate::simulation::input::PlayerInputQuery,
|
||||
object_types: &Query<&ObjectType>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Use verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(
|
||||
target_id,
|
||||
"Terminal interact: target entity not in registry"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Verify target is a Terminal
|
||||
match object_types.get(target_entity) {
|
||||
Ok(ObjectType::Terminal) => {}
|
||||
_ => {
|
||||
tracing::warn!(target_id, "Use verb on non-Terminal entity — ignored");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let Ok((player_entity, _, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(TerminalInteractRequest {
|
||||
terminal_entity: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
target_id,
|
||||
"Terminal interact: TerminalInteractRequest inserted on player"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -82,6 +82,102 @@ pub fn occupied_slots_for(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Handle Take verb: pick up an item entity and add it to the player's inventory.
|
||||
/// Removes TilePosition (item is no longer on the ground — info boundary enforcement),
|
||||
/// adds CarriedBy + InventorySlot components.
|
||||
pub fn handle_take(
|
||||
commands: &mut Commands,
|
||||
registry: &crate::knowledge::EntityRegistry,
|
||||
player_query: &crate::simulation::input::PlayerInputQuery,
|
||||
inventory_items: &Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Take verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((player_entity, _, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(player_sid) = registry.to_stable(player_entity) else {
|
||||
tracing::error!("Player entity not in EntityRegistry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Resolve wire ID to ECS entity
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Take: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Check inventory capacity
|
||||
let occupied = occupied_slots_for(player_sid, inventory_items);
|
||||
let Some(slot) = find_next_slot(&occupied) else {
|
||||
tracing::info!(
|
||||
"Inventory full ({} slots), cannot take item",
|
||||
MAX_INVENTORY_SLOTS
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Remove TilePosition (item leaves the ground), add CarriedBy + InventorySlot
|
||||
commands
|
||||
.entity(target_entity)
|
||||
.remove::<crate::simulation::movement::TilePosition>()
|
||||
.insert((CarriedBy(player_sid), InventorySlot(slot)));
|
||||
|
||||
tracing::info!(
|
||||
target_id,
|
||||
slot,
|
||||
"Take: item picked up and added to inventory slot",
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle Place verb: remove an item from inventory and place it on the ground
|
||||
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
|
||||
/// TilePosition at the player's current tile.
|
||||
pub fn handle_place(
|
||||
commands: &mut Commands,
|
||||
registry: &crate::knowledge::EntityRegistry,
|
||||
player_query: &crate::simulation::input::PlayerInputQuery,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Place verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((_, player_pos, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Place: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
let place_pos = *player_pos;
|
||||
|
||||
// Remove inventory components, place item at player's tile
|
||||
commands
|
||||
.entity(target_entity)
|
||||
.remove::<CarriedBy>()
|
||||
.remove::<InventorySlot>()
|
||||
.insert(place_pos);
|
||||
|
||||
tracing::info!(
|
||||
target_id,
|
||||
x = place_pos.x,
|
||||
y = place_pos.y,
|
||||
z = place_pos.z,
|
||||
"Place: item dropped at player position",
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -263,4 +359,304 @@ mod tests {
|
||||
fn max_slots_is_nine() {
|
||||
assert_eq!(MAX_INVENTORY_SLOTS, 9);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Take/Place verb tests (#424, D-065; moved from input.rs, T-1062)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::simulation::input::{process_player_input, InputQueue};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
fn setup_verb_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_verb_picks_up_item() {
|
||||
let mut world = setup_verb_world();
|
||||
|
||||
// Spawn player and register
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Spawn item near player
|
||||
let item = world
|
||||
.spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into())))
|
||||
.id();
|
||||
let item_sid = world.resource_mut::<EntityRegistry>().register(item);
|
||||
|
||||
// Issue Take verb
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(item_sid.0),
|
||||
verb: Some("Take".into()),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Item should have CarriedBy + InventorySlot, no TilePosition
|
||||
assert!(
|
||||
world.get::<TilePosition>(item).is_none(),
|
||||
"item should leave the ground"
|
||||
);
|
||||
let carried = world
|
||||
.get::<CarriedBy>(item)
|
||||
.expect("item should have CarriedBy");
|
||||
assert_eq!(carried.0, player_sid);
|
||||
let slot = world
|
||||
.get::<InventorySlot>(item)
|
||||
.expect("item should have slot");
|
||||
assert_eq!(slot.0, 0, "first item goes to slot 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_verb_drops_item() {
|
||||
let mut world = setup_verb_world();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Spawn item already in inventory (no TilePosition)
|
||||
let item = world
|
||||
.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Manifest Copy".into()),
|
||||
InventorySlot(0),
|
||||
))
|
||||
.id();
|
||||
let item_sid = world.resource_mut::<EntityRegistry>().register(item);
|
||||
|
||||
// Issue Place verb
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(item_sid.0),
|
||||
verb: Some("Place".into()),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Item should have TilePosition at player's location, no CarriedBy/InventorySlot
|
||||
let pos = world
|
||||
.get::<TilePosition>(item)
|
||||
.expect("item should be on ground");
|
||||
assert_eq!(
|
||||
*pos,
|
||||
TilePosition::new(5, 5, 0),
|
||||
"placed at player position"
|
||||
);
|
||||
assert!(world.get::<CarriedBy>(item).is_none(), "CarriedBy removed");
|
||||
assert!(
|
||||
world.get::<InventorySlot>(item).is_none(),
|
||||
"InventorySlot removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_verb_assigns_sequential_slots() {
|
||||
let mut world = setup_verb_world();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Item already in slot 0
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Manifest".into()),
|
||||
InventorySlot(0),
|
||||
));
|
||||
|
||||
// New item on the ground
|
||||
let item2 = world
|
||||
.spawn((TilePosition::new(5, 4, 0), ItemName("Token".into())))
|
||||
.id();
|
||||
let item2_sid = world.resource_mut::<EntityRegistry>().register(item2);
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(item2_sid.0),
|
||||
verb: Some("Take".into()),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let slot = world
|
||||
.get::<InventorySlot>(item2)
|
||||
.expect("item should have slot");
|
||||
assert_eq!(slot.0, 1, "second item goes to slot 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_verb_full_inventory_rejected() {
|
||||
let mut world = setup_verb_world();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Fill all 9 slots
|
||||
for slot in 0..MAX_INVENTORY_SLOTS {
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName(format!("Item {}", slot)),
|
||||
InventorySlot(slot),
|
||||
));
|
||||
}
|
||||
|
||||
// Try to take another item
|
||||
let item = world
|
||||
.spawn((TilePosition::new(5, 4, 0), ItemName("Overflow".into())))
|
||||
.id();
|
||||
let item_sid = world.resource_mut::<EntityRegistry>().register(item);
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(item_sid.0),
|
||||
verb: Some("Take".into()),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Item should still be on the ground
|
||||
assert!(
|
||||
world.get::<TilePosition>(item).is_some(),
|
||||
"item stays on ground"
|
||||
);
|
||||
assert!(
|
||||
world.get::<CarriedBy>(item).is_none(),
|
||||
"no CarriedBy when full"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_then_place_roundtrip() {
|
||||
// D-065: full cycle — item on ground → Take → carried → Place → ground again
|
||||
let mut world = setup_verb_world();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let item = world
|
||||
.spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into())))
|
||||
.id();
|
||||
let item_sid = world.resource_mut::<EntityRegistry>().register(item);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// Step 1: Take
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(item_sid.0),
|
||||
verb: Some("Take".into()),
|
||||
},
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<TilePosition>(item).is_none(),
|
||||
"item off ground after Take"
|
||||
);
|
||||
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
|
||||
assert_eq!(world.get::<InventorySlot>(item).unwrap().0, 0);
|
||||
|
||||
// Step 2: Place
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(item_sid.0),
|
||||
verb: Some("Place".into()),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<SimulationTime>().tick = 1;
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world
|
||||
.get::<TilePosition>(item)
|
||||
.expect("item back on ground after Place");
|
||||
assert_eq!(
|
||||
*pos,
|
||||
TilePosition::new(5, 5, 0),
|
||||
"placed at player position"
|
||||
);
|
||||
assert!(
|
||||
world.get::<CarriedBy>(item).is_none(),
|
||||
"CarriedBy removed after Place"
|
||||
);
|
||||
assert!(
|
||||
world.get::<InventorySlot>(item).is_none(),
|
||||
"InventorySlot removed after Place"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_without_target_id_is_noop() {
|
||||
// Edge case: Take verb with no target_entity_id should not panic
|
||||
let mut world = setup_verb_world();
|
||||
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: None,
|
||||
verb: Some("Take".into()),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world); // should not panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_without_target_id_is_noop() {
|
||||
// Edge case: Place verb with no target_entity_id should not panic
|
||||
let mut world = setup_verb_world();
|
||||
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: None,
|
||||
verb: Some("Place".into()),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world); // should not panic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::bridge::types::MovementStance;
|
||||
use crate::knowledge::types::SoundRange;
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind};
|
||||
use crate::simulation::stance::Stance;
|
||||
|
||||
@@ -448,6 +449,39 @@ pub fn validate_movement(
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a movement action with stance-based cooldown enforcement.
|
||||
/// If the player has a Stance and PlayerMoveCooldown, movement is throttled
|
||||
/// according to the stance's ticks_per_move. Without these components,
|
||||
/// movement is unrestricted (backward compatibility).
|
||||
pub fn apply_move(
|
||||
player_query: &mut crate::simulation::input::PlayerInputQuery,
|
||||
commands: &mut Commands,
|
||||
dx: i32,
|
||||
dy: i32,
|
||||
) {
|
||||
let (entity, pos, stance_opt, cooldown_opt) = player_query
|
||||
.single_mut()
|
||||
.expect("PlayerCharacter entity must exist when processing input");
|
||||
|
||||
let stance = stance_opt.map(|s| s.0).unwrap_or_default();
|
||||
|
||||
// Check cooldown if present
|
||||
if let Some(mut cooldown) = cooldown_opt {
|
||||
if !cooldown.try_move(stance) {
|
||||
tracing::trace!("Movement throttled by stance {:?} cooldown", stance);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands.entity(entity).insert(MoveIntent {
|
||||
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
|
||||
});
|
||||
// Update facing direction based on movement (D-015 vision cone)
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(Facing(facing_from_delta(dx, dy)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1283,4 +1317,141 @@ mod tests {
|
||||
let emitter = world.get::<SoundEventEmitter>(entity).unwrap();
|
||||
assert!((emitter.pending[0].intensity - 0.15).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// apply_move stance-cooldown throttle tests (D-053; moved from input.rs, T-1062)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::simulation::input::{process_player_input, InputQueue};
|
||||
use crate::simulation::stance::PlayerMoveCooldown;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
#[test]
|
||||
fn walk_stance_throttles_movement_to_every_2_ticks() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance::default(), // Walk
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// Tick 0: move north — should succeed (first move)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_some(),
|
||||
"first move should succeed"
|
||||
);
|
||||
|
||||
// Remove MoveIntent (simulating validate_movement consuming it)
|
||||
world.entity_mut(player).remove::<MoveIntent>();
|
||||
|
||||
// Tick 0 again: move north — should be throttled (cooldown)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_none(),
|
||||
"second move should be throttled"
|
||||
);
|
||||
|
||||
// Tick 0 again: move north — should succeed (cooldown elapsed)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_some(),
|
||||
"third move should succeed after cooldown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_stance_allows_every_tick() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance(MovementStance::Sprint),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// First move
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some());
|
||||
world.entity_mut(player).remove::<MoveIntent>();
|
||||
|
||||
// Second move — sprint allows every tick
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_some(),
|
||||
"sprint should allow every tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_stance_component_moves_unrestricted() {
|
||||
// Backward compatibility: entities without Stance/Cooldown move freely
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some());
|
||||
world.entity_mut(player).remove::<MoveIntent>();
|
||||
|
||||
// Second move immediately — no throttle without components
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,31 @@ pub struct SaveLoadPending {
|
||||
pub pending: Option<SaveLoadCommand>,
|
||||
}
|
||||
|
||||
/// Queue a save/load command from player input (#553).
|
||||
/// At most one command can be pending; a newer command overwrites it.
|
||||
/// `None` means the resource was never registered (warn and drop).
|
||||
pub fn queue_save_load(pending: Option<&mut SaveLoadPending>, cmd: SaveLoadCommand) {
|
||||
let (label, path) = match &cmd {
|
||||
SaveLoadCommand::Save { path } => ("SaveGame", path),
|
||||
SaveLoadCommand::Load { path } => ("LoadGame", path),
|
||||
};
|
||||
let Some(sl) = pending else {
|
||||
tracing::warn!(
|
||||
"{} received but SaveLoadPending resource not registered",
|
||||
label
|
||||
);
|
||||
return;
|
||||
};
|
||||
if sl.pending.is_some() {
|
||||
tracing::warn!(
|
||||
"{} overwrites already-pending save/load command (dropped)",
|
||||
label
|
||||
);
|
||||
}
|
||||
tracing::info!("{} queued: {:?}", label, path);
|
||||
sl.pending = Some(cmd);
|
||||
}
|
||||
|
||||
/// Extract world state into `SaveStateV1` and write MessagePack bytes to `path` (#553).
|
||||
///
|
||||
/// Queries all NPC entities, the player knowledge graph, global relationship graph,
|
||||
|
||||
@@ -96,6 +96,30 @@ impl PlayerMoveCooldown {
|
||||
}
|
||||
}
|
||||
|
||||
/// Step the player's stance one level up or down the ladder (D-053).
|
||||
/// No-op when the player has no Stance component or is already at the end.
|
||||
pub fn handle_toggle_stance(
|
||||
player_query: &mut crate::simulation::input::PlayerInputQuery,
|
||||
up: bool,
|
||||
) {
|
||||
if let Ok((_, _, Some(mut stance), _)) = player_query.single_mut() {
|
||||
let new_stance = if up {
|
||||
stance.0.step_up()
|
||||
} else {
|
||||
stance.0.step_down()
|
||||
};
|
||||
if new_stance != stance.0 {
|
||||
tracing::debug!(
|
||||
"Stance {}: {:?} -> {:?}",
|
||||
if up { "up" } else { "down" },
|
||||
stance.0,
|
||||
new_stance
|
||||
);
|
||||
stance.0 = new_stance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -253,4 +277,69 @@ mod tests {
|
||||
let stance = world.get::<Stance>(entity).unwrap();
|
||||
assert_eq!(stance.0, MovementStance::Walk);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// handle_toggle_stance ladder tests (moved from input.rs, T-1062)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::simulation::input::{process_player_input, InputQueue};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
#[test]
|
||||
fn toggle_stance_up_changes_stance() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance::default(),
|
||||
PlayerMoveCooldown::default(),
|
||||
));
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ToggleStanceUp,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut query = world.query::<&Stance>();
|
||||
let stance = query.single(&world).unwrap();
|
||||
assert_eq!(stance.0, MovementStance::Sprint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_stance_down_changes_stance() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance::default(),
|
||||
PlayerMoveCooldown::default(),
|
||||
));
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ToggleStanceDown,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut query = world.query::<&Stance>();
|
||||
let stance = query.single(&world).unwrap();
|
||||
assert_eq!(stance.0, MovementStance::Careful);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ pub mod invariants;
|
||||
pub mod reset;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod rooms;
|
||||
pub mod teleport;
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
@@ -115,6 +115,67 @@ impl RoomSnapshots {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Reset verb: restore a room's entities to their initial positions.
|
||||
/// Target entity must have a RoomResetTrigger component. Respects debounce.
|
||||
pub fn handle_reset(
|
||||
commands: &mut Commands,
|
||||
registry: &crate::knowledge::EntityRegistry,
|
||||
reset_triggers: &Query<&RoomResetTrigger>,
|
||||
room_snapshots: &mut Option<ResMut<RoomSnapshots>>,
|
||||
target_entity_id: Option<u64>,
|
||||
current_tick: u64,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Reset verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(snapshots) = room_snapshots.as_mut() else {
|
||||
tracing::warn!("Reset verb but RoomSnapshots resource not available");
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = crate::knowledge::StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Reset: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(trigger) = reset_triggers.get(target_entity) else {
|
||||
tracing::warn!(target_id, "Reset: target is not a reset trigger");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(changes) = snapshots.plan_reset(&trigger.room_name, current_tick) else {
|
||||
tracing::info!(
|
||||
room = trigger.room_name.as_str(),
|
||||
"Reset: debounced or unknown room"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let mut restored = 0;
|
||||
for (entity, position, is_floor_item) in changes {
|
||||
if is_floor_item {
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<crate::simulation::inventory::CarriedBy>()
|
||||
.remove::<crate::simulation::inventory::InventorySlot>()
|
||||
.insert(position);
|
||||
} else {
|
||||
commands.entity(entity).insert(position);
|
||||
}
|
||||
restored += 1;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
room = trigger.room_name.as_str(),
|
||||
restored,
|
||||
current_tick,
|
||||
"Room reset executed via Reset verb"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -208,4 +269,221 @@ mod tests {
|
||||
assert!(snapshots.can_reset("room", 110));
|
||||
assert!(snapshots.can_reset("room", 200));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reset verb tests (#490; moved from input.rs, T-1062)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::simulation::input::{process_player_input, InputQueue};
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
#[test]
|
||||
fn reset_verb_restores_floor_item() {
|
||||
// #490: Take a floor item, then Reset verb restores it to original position.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
|
||||
// Player
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Floor item at (5, 4)
|
||||
let item = world
|
||||
.spawn((TilePosition::new(5, 4, 0), ItemName("Keycard".into())))
|
||||
.id();
|
||||
let item_sid = world.resource_mut::<EntityRegistry>().register(item);
|
||||
|
||||
// Reset plate entity
|
||||
let plate = world
|
||||
.spawn((
|
||||
crate::simulation::interaction::Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: "test_room".to_string(),
|
||||
},
|
||||
TilePosition::new(5, 3, 0),
|
||||
))
|
||||
.id();
|
||||
let plate_sid = world.resource_mut::<EntityRegistry>().register(plate);
|
||||
|
||||
// Record snapshot: item is a floor item at its original position
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
snapshots.record("test_room", item, TilePosition::new(5, 4, 0), true);
|
||||
world.insert_resource(snapshots);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// Step 1: Take the item
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(item_sid.0),
|
||||
verb: Some("Take".into()),
|
||||
},
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<TilePosition>(item).is_none(),
|
||||
"Item should be picked up"
|
||||
);
|
||||
assert_eq!(world.get::<CarriedBy>(item).unwrap().0, player_sid);
|
||||
|
||||
// Step 2: Reset via verb
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<SimulationTime>().tick = 1;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Item should be back on the ground at original position
|
||||
let pos = world
|
||||
.get::<TilePosition>(item)
|
||||
.expect("Item should be restored to ground");
|
||||
assert_eq!(
|
||||
*pos,
|
||||
TilePosition::new(5, 4, 0),
|
||||
"Item at original position"
|
||||
);
|
||||
assert!(
|
||||
world.get::<CarriedBy>(item).is_none(),
|
||||
"CarriedBy removed after reset"
|
||||
);
|
||||
assert!(
|
||||
world.get::<InventorySlot>(item).is_none(),
|
||||
"InventorySlot removed after reset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_verb_debounces() {
|
||||
// #490: Reset debounce prevents rapid-fire resets.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
|
||||
let _player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
|
||||
// NPC entity
|
||||
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Reset plate
|
||||
let plate = world
|
||||
.spawn((
|
||||
crate::simulation::interaction::Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: "test_room".to_string(),
|
||||
},
|
||||
TilePosition::new(5, 3, 0),
|
||||
))
|
||||
.id();
|
||||
let plate_sid = world.resource_mut::<EntityRegistry>().register(plate);
|
||||
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
snapshots.record("test_room", npc, TilePosition::new(10, 10, 0), false);
|
||||
world.insert_resource(snapshots);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// First reset at tick 0 — should succeed
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Move NPC to verify debounce blocks second reset
|
||||
*world.get_mut::<TilePosition>(npc).unwrap() = TilePosition::new(20, 20, 0);
|
||||
|
||||
// Second reset at tick 5 — should be debounced (< 10 ticks)
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 5,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<SimulationTime>().tick = 5;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// NPC should still be at moved position (reset was debounced)
|
||||
assert_eq!(
|
||||
world.get::<TilePosition>(npc).unwrap().x,
|
||||
20,
|
||||
"NPC not reset — debounced"
|
||||
);
|
||||
|
||||
// Third reset at tick 10 — should succeed
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 10,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
world.resource_mut::<SimulationTime>().tick = 10;
|
||||
schedule.run(&mut world);
|
||||
|
||||
// NPC should be back at original position
|
||||
assert_eq!(
|
||||
world.get::<TilePosition>(npc).unwrap().x,
|
||||
10,
|
||||
"NPC reset after debounce elapsed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_verb_without_snapshots_is_noop() {
|
||||
// Reset verb when no RoomSnapshots resource exists should not panic.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
||||
|
||||
let plate = world
|
||||
.spawn((
|
||||
crate::simulation::interaction::Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: "test_room".to_string(),
|
||||
},
|
||||
TilePosition::new(5, 3, 0),
|
||||
))
|
||||
.id();
|
||||
let plate_sid = world.resource_mut::<EntityRegistry>().register(plate);
|
||||
|
||||
// No RoomSnapshots resource inserted — should be gracefully handled
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(plate_sid.0),
|
||||
verb: Some("Reset".into()),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world); // should not panic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! TeleportToHub QA action (#491) — Gauntlet-only return-to-hub verb.
|
||||
//!
|
||||
//! Moved out of simulation::input with the dispatcher split (T-1062): the
|
||||
//! action only exists on the Gauntlet test world, so it lives with it.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::simulation::input::PlayerInputQuery;
|
||||
|
||||
/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491).
|
||||
///
|
||||
/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning
|
||||
/// and returns. On Gauntlet maps, moves the player to HUB.spawn and removes
|
||||
/// dialogue, monologue, and interaction markers to prevent stale state.
|
||||
///
|
||||
/// Does NOT affect: room state, inventory, game time, knowledge graph.
|
||||
pub fn handle_teleport_to_hub(player_query: &mut PlayerInputQuery, commands: &mut Commands) {
|
||||
#[cfg(not(feature = "gauntlet"))]
|
||||
{
|
||||
tracing::warn!("TeleportToHub rejected: not a Gauntlet map");
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
{
|
||||
let Ok((player_entity, _, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
|
||||
// Move player to hub spawn
|
||||
commands.entity(player_entity).insert(hub_spawn);
|
||||
|
||||
// Clear any pending movement
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.remove::<crate::simulation::movement::MoveIntent>();
|
||||
|
||||
// Clear dialogue/interaction markers (including mid-confrontation state)
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.remove::<crate::simulation::dialogue::TalkRequest>()
|
||||
.remove::<crate::simulation::dialogue::ActiveDialogue>()
|
||||
.remove::<crate::simulation::dialogue::WalkAwayRequest>()
|
||||
.remove::<crate::simulation::dialogue::ConfrontationDelivered>()
|
||||
.remove::<crate::simulation::dialogue::DialogueResponseRequest>();
|
||||
|
||||
tracing::info!(
|
||||
x = hub_spawn.x,
|
||||
y = hub_spawn.y,
|
||||
z = hub_spawn.z,
|
||||
"TeleportToHub: player moved to hub spawn"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests (#491; moved from input.rs, T-1062)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(all(test, feature = "gauntlet"))]
|
||||
mod tests {
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::simulation::input::{process_player_input, InputQueue};
|
||||
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::{SimulationTime, TickRate};
|
||||
|
||||
#[test]
|
||||
fn teleport_to_hub_moves_player() {
|
||||
// #491: TeleportToHub moves player to hub spawn position.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Spawn player at a non-hub position
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world
|
||||
.get::<TilePosition>(player)
|
||||
.expect("player has position");
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn");
|
||||
assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn");
|
||||
assert_eq!(pos.z, hub_spawn.z, "player z at hub spawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_to_hub_clears_dialogue_markers() {
|
||||
// #491: TeleportToHub removes ActiveDialogue, TalkRequest,
|
||||
// WalkAwayRequest, and ConfrontationDelivered.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Spawn a fake NPC target
|
||||
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
|
||||
|
||||
// Spawn player with active dialogue state + mid-confrontation marker
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(84, 58, 0),
|
||||
crate::simulation::dialogue::TalkRequest { target: npc },
|
||||
crate::simulation::dialogue::ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 0,
|
||||
},
|
||||
crate::simulation::dialogue::WalkAwayRequest,
|
||||
crate::simulation::dialogue::ConfrontationDelivered { target: npc },
|
||||
))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::TalkRequest>(player)
|
||||
.is_none(),
|
||||
"TalkRequest cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::ActiveDialogue>(player)
|
||||
.is_none(),
|
||||
"ActiveDialogue cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::WalkAwayRequest>(player)
|
||||
.is_none(),
|
||||
"WalkAwayRequest cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::ConfrontationDelivered>(player)
|
||||
.is_none(),
|
||||
"ConfrontationDelivered cleared after teleport"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_to_hub_clears_move_intent() {
|
||||
// #491: TeleportToHub removes any pending MoveIntent.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(84, 58, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(85, 58, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_none(),
|
||||
"MoveIntent cleared after teleport"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_to_hub_allowed_while_paused() {
|
||||
// #491: TeleportToHub is a QA action — allowed even when paused.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick_rate = TickRate::Paused;
|
||||
world.insert_resource(time);
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world
|
||||
.get::<TilePosition>(player)
|
||||
.expect("player has position");
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user