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:
2026-06-12 17:07:36 +02:00
co-authored by Claude Fable 5
parent 3a23378a88
commit 257d979aed
23 changed files with 4819 additions and 4735 deletions
@@ -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;
+5
View File
@@ -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);
+253
View File
@@ -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"));
}
}
+11
View File
@@ -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
// ---------------------------------------------------------------------------
+28
View File
@@ -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(
+19
View File
@@ -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"
);
}
}
+226
View File
@@ -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
+631
View File
@@ -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
);
}
}
+14
View File
@@ -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`.
///
+48
View File
@@ -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
// ---------------------------------------------------------------------------
+52
View File
@@ -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
// ---------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+90
View File
@@ -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::*;
+396
View File
@@ -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
}
}
+171
View File
@@ -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());
}
}
+25
View File
@@ -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,
+89
View File
@@ -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);
}
}
+1
View File
@@ -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::*;
+278
View File
@@ -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
}
}
+225
View File
@@ -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");
}
}