feat(simulation): multi-threaded executor + EntityRng (#843 Part B)
Enable Bevy multi-threaded executor via bevy_tasks multi_threaded feature. Systems within the same TickPhase that don't share mutable resources now run in parallel automatically. Add EntityRng component — per-entity ChaCha20Rng seeded from world_seed + StableId via splitmix64 mixing. More deterministic than shared SimRng (order-independent). Migrate all monologue systems (4 of 13 SimRng consumers) to EntityRng, removing contention that serialized them against conversation/dialogue systems. Add rayon dependency (infrastructure only, no par_iter calls yet). SimRng retained for world-level randomness: conversation pairing, knowledge transfer, dialogue, ticker, storyteller. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
|
||||
use crate::perception::interpretation::ObservationTrigger;
|
||||
use crate::simulation::conversation::NpcName;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::rng::EntityRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::storyteller::EngagementRecord;
|
||||
|
||||
@@ -242,17 +242,17 @@ impl SprintAnomalyQueue {
|
||||
/// System ordering: after trigger_monologue, before compute_observer_snapshot.
|
||||
pub fn process_sprint_anomaly_monologue(
|
||||
time: Res<SimulationTime>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut query: Query<
|
||||
(
|
||||
&mut SprintAnomalyQueue,
|
||||
&mut MonologueBuffer,
|
||||
&mut MonologueState,
|
||||
&mut EntityRng,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
) {
|
||||
let Ok((mut queue, mut buffer, mut state)) = query.single_mut() else {
|
||||
let Ok((mut queue, mut buffer, mut state, mut entity_rng)) = query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -262,7 +262,7 @@ pub fn process_sprint_anomaly_monologue(
|
||||
}
|
||||
|
||||
if let Some(_entity_id) = queue.take_ready(time.tick) {
|
||||
let index = rng.rng.random_range(0..ANOMALY_LINES.len());
|
||||
let index = entity_rng.rng.random_range(0..ANOMALY_LINES.len());
|
||||
let (id, text) = ANOMALY_LINES[index];
|
||||
|
||||
buffer.event = Some(MonologueEvent {
|
||||
@@ -295,18 +295,19 @@ pub fn process_sprint_anomaly_monologue(
|
||||
/// System ordering: after trigger_monologue, before process_sprint_anomaly_monologue.
|
||||
pub fn trigger_recognition_monologue(
|
||||
time: Res<SimulationTime>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut query: Query<
|
||||
(
|
||||
&mut crate::perception::cognitive_delay::CognitiveDelay,
|
||||
&mut MonologueBuffer,
|
||||
&mut MonologueState,
|
||||
&mut EntityRng,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
|
||||
) {
|
||||
let Ok((mut cognitive_delay, mut buffer, mut state)) = query.single_mut() else {
|
||||
let Ok((mut cognitive_delay, mut buffer, mut state, mut entity_rng)) = query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -339,7 +340,7 @@ pub fn trigger_recognition_monologue(
|
||||
return;
|
||||
};
|
||||
|
||||
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
|
||||
let i = entity_rng.rng.random_range(0..RECOGNITION_LINES.len());
|
||||
let (id, text) = (
|
||||
RECOGNITION_LINES[i].0.to_string(),
|
||||
RECOGNITION_LINES[i].1.to_string(),
|
||||
@@ -420,7 +421,6 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn trigger_event_monologue(
|
||||
time: Res<SimulationTime>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
observation_queue: Option<Res<crate::perception::interpretation::ObservationEventQueue>>,
|
||||
sound_queue: Option<Res<crate::simulation::sound::SoundEventQueue>>,
|
||||
mut post_conv_queue: ResMut<PostConversationQueue>,
|
||||
@@ -430,6 +430,7 @@ pub fn trigger_event_monologue(
|
||||
&mut MonologueState,
|
||||
&mut MonologueBuffer,
|
||||
Option<&crate::simulation::conversation::ConversationEventBuffer>,
|
||||
&mut EntityRng,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
@@ -440,7 +441,9 @@ pub fn trigger_event_monologue(
|
||||
// Saved for NPC attribution (engagement tracking #570) and trigger detection.
|
||||
let post_conv_npcs: Vec<Entity> = post_conv_queue.drain();
|
||||
|
||||
let Ok((player_pos, mut state, mut buffer, conv_buffer_opt)) = query.single_mut() else {
|
||||
let Ok((player_pos, mut state, mut buffer, conv_buffer_opt, mut entity_rng)) =
|
||||
query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -489,7 +492,7 @@ pub fn trigger_event_monologue(
|
||||
|
||||
let Some(trigger) = trigger else { return };
|
||||
|
||||
let (id, text) = select_hardcoded_fallback(trigger, &mut rng.rng);
|
||||
let (id, text) = select_hardcoded_fallback(trigger, &mut entity_rng.rng);
|
||||
|
||||
buffer.event = Some(MonologueEvent {
|
||||
id: id.clone(),
|
||||
@@ -584,7 +587,6 @@ fn has_hear_sound_event(
|
||||
/// Remove this stub when those systems' ordering constraints are refactored.
|
||||
pub fn trigger_monologue(
|
||||
_time: Res<SimulationTime>,
|
||||
_rng: ResMut<SimRng>,
|
||||
_query: Query<
|
||||
(&TilePosition, &mut MonologueState, &mut MonologueBuffer),
|
||||
With<PlayerCharacter>,
|
||||
@@ -635,10 +637,12 @@ pub(crate) fn resolve_name(
|
||||
pub fn process_contradiction_monologue(
|
||||
time: Res<SimulationTime>,
|
||||
_registry: Res<EntityRegistry>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
|
||||
_npc_names: Query<&NpcName>,
|
||||
mut player_query: Query<(&mut MonologueBuffer, &mut MonologueState), With<PlayerCharacter>>,
|
||||
mut player_query: Query<
|
||||
(&mut MonologueBuffer, &mut MonologueState, &mut EntityRng),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
) {
|
||||
// _registry and _npc_names are available for future triggers needing live name resolution
|
||||
// via resolve_name(). ContradictionDetected uses pre-resolved names from the event payload.
|
||||
@@ -647,7 +651,7 @@ pub fn process_contradiction_monologue(
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok((mut buffer, mut state)) = player_query.single_mut() else {
|
||||
let Ok((mut buffer, mut state, mut entity_rng)) = player_query.single_mut() else {
|
||||
contradiction_queue.drain();
|
||||
return;
|
||||
};
|
||||
@@ -664,7 +668,7 @@ pub fn process_contradiction_monologue(
|
||||
return;
|
||||
};
|
||||
|
||||
let template_idx = rng.rng.random_range(0..CONTRADICTION_TEMPLATE_LINES.len());
|
||||
let template_idx = entity_rng.rng.random_range(0..CONTRADICTION_TEMPLATE_LINES.len());
|
||||
let text = CONTRADICTION_TEMPLATE_LINES[template_idx]
|
||||
.replace("{source}", &event.source_display_name)
|
||||
.replace("{subject}", &event.subject_display_name);
|
||||
|
||||
@@ -6,8 +6,11 @@ use bevy_ecs::prelude::*;
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
/// Simulation RNG resource
|
||||
/// ChaCha20 RNG with stored seed for deterministic replay
|
||||
/// Simulation RNG resource — shared world-level randomness.
|
||||
///
|
||||
/// Used for non-entity randomness: storyteller dice, world events, ticker rotation.
|
||||
/// For entity-level randomness, use [`EntityRng`] instead — it enables parallelism
|
||||
/// because each entity has its own independent, deterministic RNG stream.
|
||||
#[derive(Resource)]
|
||||
pub struct SimRng {
|
||||
pub rng: ChaCha20Rng,
|
||||
@@ -29,6 +32,43 @@ impl SimRng {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-entity RNG component — deterministic, parallel-safe (#843).
|
||||
///
|
||||
/// Each entity gets its own ChaCha20 stream seeded from `world_seed + StableId`.
|
||||
/// Systems that need randomness for a specific entity query `&mut EntityRng`
|
||||
/// instead of `ResMut<SimRng>`. This unlocks parallelism: Bevy can run systems
|
||||
/// on disjoint entity sets concurrently.
|
||||
///
|
||||
/// Determinism: same `world_seed` + same `StableId` = same RNG sequence,
|
||||
/// regardless of thread execution order.
|
||||
#[derive(Component)]
|
||||
pub struct EntityRng {
|
||||
pub rng: ChaCha20Rng,
|
||||
}
|
||||
|
||||
impl EntityRng {
|
||||
/// Create a new EntityRng from a world seed and entity stable ID.
|
||||
///
|
||||
/// Uses splitmix64 mixing to combine the two seeds with good avalanche
|
||||
/// properties — avoids correlated sequences for entities with similar IDs.
|
||||
pub fn from_seed_and_id(world_seed: u64, stable_id: u64) -> Self {
|
||||
let mixed = splitmix64(world_seed.wrapping_add(stable_id));
|
||||
Self {
|
||||
rng: ChaCha20Rng::seed_from_u64(mixed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splitmix64 mixing function — good avalanche properties for seed derivation.
|
||||
/// Ensures that similar inputs (e.g., consecutive StableIds) produce
|
||||
/// uncorrelated output seeds.
|
||||
fn splitmix64(mut x: u64) -> u64 {
|
||||
x = x.wrapping_add(0x9e3779b97f4a7c15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
|
||||
x ^ (x >> 31)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user